regscale-cli 6.21.1.0__py3-none-any.whl → 6.21.2.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.
Files changed (34) hide show
  1. regscale/_version.py +1 -1
  2. regscale/core/app/application.py +7 -0
  3. regscale/integrations/commercial/__init__.py +8 -8
  4. regscale/integrations/commercial/import_all/import_all_cmd.py +2 -2
  5. regscale/integrations/commercial/microsoft_defender/__init__.py +0 -0
  6. regscale/integrations/commercial/{defender.py → microsoft_defender/defender.py} +38 -612
  7. regscale/integrations/commercial/microsoft_defender/defender_api.py +286 -0
  8. regscale/integrations/commercial/microsoft_defender/defender_constants.py +80 -0
  9. regscale/integrations/commercial/microsoft_defender/defender_scanner.py +168 -0
  10. regscale/integrations/commercial/qualys/__init__.py +24 -86
  11. regscale/integrations/commercial/qualys/containers.py +2 -0
  12. regscale/integrations/commercial/qualys/scanner.py +7 -2
  13. regscale/integrations/commercial/sonarcloud.py +110 -71
  14. regscale/integrations/commercial/wizv2/click.py +4 -1
  15. regscale/integrations/commercial/wizv2/data_fetcher.py +401 -0
  16. regscale/integrations/commercial/wizv2/finding_processor.py +295 -0
  17. regscale/integrations/commercial/wizv2/policy_compliance.py +1402 -203
  18. regscale/integrations/commercial/wizv2/policy_compliance_helpers.py +564 -0
  19. regscale/integrations/commercial/wizv2/scanner.py +4 -4
  20. regscale/integrations/compliance_integration.py +212 -60
  21. regscale/integrations/public/fedramp/fedramp_five.py +92 -7
  22. regscale/integrations/scanner_integration.py +27 -4
  23. regscale/models/__init__.py +1 -1
  24. regscale/models/integration_models/cisa_kev_data.json +33 -3
  25. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  26. regscale/models/regscale_models/issue.py +29 -9
  27. {regscale_cli-6.21.1.0.dist-info → regscale_cli-6.21.2.0.dist-info}/METADATA +1 -1
  28. {regscale_cli-6.21.1.0.dist-info → regscale_cli-6.21.2.0.dist-info}/RECORD +32 -27
  29. tests/regscale/test_authorization.py +0 -65
  30. tests/regscale/test_init.py +0 -96
  31. {regscale_cli-6.21.1.0.dist-info → regscale_cli-6.21.2.0.dist-info}/LICENSE +0 -0
  32. {regscale_cli-6.21.1.0.dist-info → regscale_cli-6.21.2.0.dist-info}/WHEEL +0 -0
  33. {regscale_cli-6.21.1.0.dist-info → regscale_cli-6.21.2.0.dist-info}/entry_points.txt +0 -0
  34. {regscale_cli-6.21.1.0.dist-info → regscale_cli-6.21.2.0.dist-info}/top_level.txt +0 -0
@@ -9,8 +9,8 @@ import re
9
9
  from datetime import datetime
10
10
  from typing import Dict, List, Optional, Iterator, Any
11
11
 
12
- from regscale.core.app.utils.app_utils import error_and_exit, check_license
13
12
  from regscale.core.app.application import Application
13
+ from regscale.core.app.utils.app_utils import error_and_exit, check_license, get_current_datetime
14
14
  from regscale.integrations.commercial.wizv2.async_client import run_async_queries
15
15
  from regscale.integrations.commercial.wizv2.constants import (
16
16
  WizVulnerabilityType,
@@ -20,23 +20,36 @@ from regscale.integrations.commercial.wizv2.constants import (
20
20
  FRAMEWORK_SHORTCUTS,
21
21
  FRAMEWORK_CATEGORIES,
22
22
  )
23
+ from regscale.integrations.commercial.wizv2.data_fetcher import PolicyAssessmentFetcher
24
+ from regscale.integrations.commercial.wizv2.finding_processor import (
25
+ FindingConsolidator,
26
+ FindingToIssueProcessor,
27
+ )
28
+ from regscale.integrations.commercial.wizv2.policy_compliance_helpers import (
29
+ ControlImplementationCache,
30
+ AssetConsolidator,
31
+ IssueFieldSetter,
32
+ ControlAssessmentProcessor,
33
+ )
23
34
  from regscale.integrations.commercial.wizv2.wiz_auth import wiz_authenticate
24
35
  from regscale.integrations.compliance_integration import ComplianceIntegration, ComplianceItem
25
36
  from regscale.integrations.scanner_integration import (
26
37
  ScannerIntegrationType,
27
38
  IntegrationAsset,
28
39
  IntegrationFinding,
40
+ issue_due_date,
29
41
  )
30
42
  from regscale.models import regscale_models
31
43
 
32
44
  logger = logging.getLogger("regscale")
33
45
 
34
46
 
47
+ # Constants for file operations
35
48
  JSON_FILE_EXT = ".json"
36
49
  JSONL_FILE_EXT = ".jsonl"
37
-
38
- ## WIZ_POLICY_QUERY moved to constants
39
-
50
+ MAX_DISPLAY_ASSETS = 10 # Maximum number of asset names to display in descriptions
51
+ CACHE_CLEANUP_KEEP_COUNT = 5 # Number of recent cache files to keep during cleanup
52
+ WIZ_URL = "https://api.wiz.io/graphql"
40
53
 
41
54
  # Safer, linear-time regex for control-id normalization.
42
55
  # Examples supported: 'AC-4', 'AC-4(2)', 'AC-4 (2)', 'AC-4-2', 'AC-4 2'
@@ -83,8 +96,8 @@ class WizComplianceItem(ComplianceItem):
83
96
  filtered = [
84
97
  sc for sc in subcategories if sc.get("category", {}).get("framework", {}).get("id") == target_framework_id
85
98
  ]
86
- # Fallback to original list if filter removes everything (defensive)
87
- return filtered if filtered else subcategories
99
+ # Return filtered results - if empty, the control_id will be empty (framework filtering working as intended)
100
+ return filtered
88
101
 
89
102
  @property
90
103
  def resource_id(self) -> str:
@@ -178,12 +191,15 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
178
191
 
179
192
  title = "Wiz Policy Compliance Integration"
180
193
  type = ScannerIntegrationType.CONTROL_TEST
181
- # Enable component creation/mapping like scanner integrations
182
- options_map_assets_to_components: bool = True
194
+ # Use wizId field for asset identification (matches other Wiz integrations)
195
+ asset_identifier_field = "wizId"
196
+ # Do not create assets - they come from separate inventory import
197
+ options_map_assets_to_components: bool = False
183
198
  # Do not create vulnerabilities from compliance policy results
184
199
  create_vulnerabilities: bool = False
185
- # Enable scan history; we will record issue counts
186
- enable_scan_history: bool = True
200
+ # Do not create scan history - this is compliance report ingest, not a vulnerability scan
201
+ enable_scan_history: bool = False
202
+
187
203
  # Control whether JSONL control-centric export is written alongside JSON
188
204
  write_jsonl_output: bool = False
189
205
 
@@ -199,6 +215,7 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
199
215
  create_issues: bool = True,
200
216
  update_control_status: bool = True,
201
217
  create_poams: bool = False,
218
+ regscale_module: Optional[str] = "securityplans",
202
219
  **kwargs,
203
220
  ):
204
221
  """
@@ -214,9 +231,11 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
214
231
  :param bool create_issues: Whether to create issues for failed compliance
215
232
  :param bool update_control_status: Whether to update control implementation status
216
233
  :param bool create_poams: Whether to mark issues as POAMs
234
+ :param Optional[str] regscale_module: RegScale module string (overrides default parent_module)
217
235
  """
218
236
  super().__init__(
219
237
  plan_id=plan_id,
238
+ parent_module=regscale_module,
220
239
  catalog_id=catalog_id,
221
240
  framework=self._map_framework_id_to_name(framework_id),
222
241
  create_issues=create_issues,
@@ -226,6 +245,10 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
226
245
  **kwargs,
227
246
  )
228
247
 
248
+ # Override parent_module if regscale_module is provided
249
+ if regscale_module:
250
+ self.parent_module = regscale_module
251
+
229
252
  self.wiz_project_id = wiz_project_id
230
253
  self.client_id = client_id
231
254
  self.client_secret = client_secret
@@ -245,21 +268,87 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
245
268
  self.policy_cache_dir, f"policy_assessments_{wiz_project_id}_{framework_id}.json"
246
269
  )
247
270
 
271
+ # Initialize helper classes for cleaner code organization
272
+ self._control_cache = ControlImplementationCache()
273
+ self._asset_consolidator = AssetConsolidator()
274
+ self._issue_field_setter = IssueFieldSetter(self._control_cache, plan_id, regscale_module or "securityplans")
275
+ self._finding_consolidator = FindingConsolidator(self)
276
+ self._finding_processor = FindingToIssueProcessor(self)
277
+ self._assessment_processor = ControlAssessmentProcessor(
278
+ plan_id,
279
+ regscale_module or "securityplans",
280
+ self.scan_date,
281
+ self.title,
282
+ self._map_framework_id_to_name(framework_id),
283
+ )
284
+
248
285
  def fetch_compliance_data(self) -> List[Any]:
249
286
  """
250
- Fetch compliance data from Wiz GraphQL API.
287
+ Fetch compliance data from Wiz GraphQL API and filter to framework-specific
288
+ items for existing assets only.
251
289
 
252
- :return: List of raw compliance data (will be converted by base class)
290
+ :return: List of filtered raw compliance data
253
291
  :rtype: List[Any]
254
292
  """
255
293
  # Authenticate if not already done
256
294
  if not self.access_token:
257
295
  self.authenticate_wiz()
258
296
 
259
- # Fetch raw policy assessments and return them
260
- # The base class will call create_compliance_item() on each
261
- self.raw_policy_assessments = self._fetch_policy_assessments_from_wiz()
262
- return self.raw_policy_assessments
297
+ # Load existing assets early for filtering
298
+ self._load_regscale_assets()
299
+
300
+ # Use the data fetcher for cleaner code
301
+ fetcher = PolicyAssessmentFetcher(
302
+ wiz_endpoint=self.wiz_endpoint or WIZ_URL,
303
+ access_token=self.access_token,
304
+ wiz_project_id=self.wiz_project_id,
305
+ framework_id=self.framework_id,
306
+ cache_duration_minutes=self.cache_duration_minutes,
307
+ )
308
+
309
+ all_policy_assessments = fetcher.fetch_policy_assessments()
310
+
311
+ if not all_policy_assessments:
312
+ logger.info("No policy assessments fetched from Wiz")
313
+ self.raw_policy_assessments = []
314
+ return []
315
+
316
+ # Filter to only items with existing assets in RegScale
317
+ filtered_assessments = self._filter_assessments_to_existing_assets(all_policy_assessments)
318
+
319
+ self.raw_policy_assessments = filtered_assessments
320
+ return filtered_assessments
321
+
322
+ def _filter_assessments_to_existing_assets(self, assessments: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
323
+ """
324
+ Filter assessments to only include items with existing assets and control IDs.
325
+
326
+ :param assessments: List of raw assessments from Wiz
327
+ :return: Filtered list of assessments
328
+ """
329
+ assets_exist = getattr(self, "_regscale_assets_by_wiz_id", {})
330
+ filtered_assessments = []
331
+ skipped_no_control = 0
332
+ skipped_no_asset = 0
333
+
334
+ for assessment in assessments:
335
+ # Convert to compliance item to check framework and asset existence
336
+ temp_item = WizComplianceItem(assessment, self)
337
+
338
+ # Skip if no control ID (not in selected framework)
339
+ if not temp_item.control_id:
340
+ skipped_no_control += 1
341
+ continue
342
+
343
+ # Skip if asset doesn't exist in RegScale (use cached lookup)
344
+ if temp_item.resource_id not in assets_exist:
345
+ skipped_no_asset += 1
346
+ continue
347
+
348
+ filtered_assessments.append(assessment)
349
+ logger.debug(f"Skipped {skipped_no_control} assessments with no control ID for framework.")
350
+ logger.debug(f"Skipped {skipped_no_asset} assessments with no existing asset in RegScale.")
351
+ return filtered_assessments
263
352
 
264
353
  def create_compliance_item(self, raw_data: Any) -> ComplianceItem:
265
354
  """
@@ -309,66 +398,268 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
309
398
 
310
399
  def fetch_assets(self, *args, **kwargs) -> Iterator[IntegrationAsset]:
311
400
  """
312
- Fetch assets grouped to components by asset types like scanner integrations,
313
- and upsert existing assets (no duplicates). Only assets for items already
314
- filtered to the selected framework are considered.
401
+ No assets are created in policy compliance integration.
402
+ Assets come from separate Wiz inventory import.
403
+ """
404
+ return iter([])
405
+
406
+ def fetch_findings(self, *args, **kwargs) -> Iterator[IntegrationFinding]:
407
+ """
408
+ Create consolidated findings grouped by control, with all affected resources under each control.
315
409
 
316
- - Deduplicate by resource_id
317
- - Yield assets with component_names set to their inferred group
318
- - Always yield unique assets for bulk upsert (create or update)
410
+ This approach groups by control first, then collects all resources that fail that control.
411
+ This results in one finding per control with multiple resources, making consolidation much easier.
319
412
  """
320
- logger.info("Fetching assets from compliance items...")
413
+ if not self.failed_compliance_items:
414
+ return
321
415
 
322
- # Ensure caches are loaded for downstream lookups
323
- self._load_existing_records_cache()
416
+ # Use the finding consolidator for cleaner code
417
+ yield from self._finding_consolidator.create_consolidated_findings(self.failed_compliance_items)
324
418
 
325
- processed_resources = set()
326
- for compliance_item in self.all_compliance_items:
327
- resource_id = getattr(compliance_item, "resource_id", None)
328
- if not resource_id or resource_id in processed_resources:
329
- continue
419
+ def _get_all_control_ids_for_compliance_item(self, compliance_item: WizComplianceItem) -> List[str]:
420
+ """
421
+ Get ALL control IDs that a compliance item maps to.
330
422
 
331
- asset = self.create_asset_from_compliance_item(compliance_item)
332
- if asset:
333
- # Derive component grouping from the source asset type (not control)
334
- component_name = self._get_component_name_from_source_type(compliance_item)
335
- if isinstance(getattr(asset, "component_names", None), list) and component_name:
336
- if component_name not in asset.component_names:
337
- asset.component_names.append(component_name)
423
+ Wiz policies can map to multiple controls (e.g., one policy failure might affect
424
+ AC-4(2), AC-4(4), and SC-28(1) controls). This method returns all of them.
338
425
 
339
- processed_resources.add(resource_id)
340
- yield asset
426
+ :param WizComplianceItem compliance_item: Compliance item to extract control IDs from
427
+ :return: List of control IDs this policy maps to
428
+ :rtype: List[str]
429
+ """
430
+ if not compliance_item.policy:
431
+ return []
341
432
 
342
- def fetch_findings(self, *args, **kwargs) -> Iterator[IntegrationFinding]:
433
+ subcategories = compliance_item._get_filtered_subcategories()
434
+ if not subcategories:
435
+ return []
436
+
437
+ # Extract control IDs and deduplicate in one pass
438
+ unique_control_ids = []
439
+ seen = set()
440
+
441
+ for subcat in subcategories:
442
+ external_id = subcat.get("externalId", "")
443
+ if external_id and external_id not in seen:
444
+ seen.add(external_id)
445
+ unique_control_ids.append(external_id)
446
+
447
+ return unique_control_ids
448
+
449
+ def _group_compliance_items_by_control(self) -> Dict[str, Dict[str, WizComplianceItem]]:
343
450
  """
344
- Produce at most one finding per (asset, control) pair to avoid duplicates.
451
+ Group failed compliance items by control ID.
345
452
 
346
- Dedupe key: (resource_id, control_id), case-insensitive.
453
+ :return: Dictionary mapping control IDs to resource dictionaries
454
+ :rtype: Dict[str, Dict[str, WizComplianceItem]]
347
455
  """
348
- logger.info("Fetching findings from failed compliance items (dedup by asset-control)...")
456
+ control_to_resources = {} # {control_id: {resource_id: compliance_item}}
349
457
 
350
- seen_keys: set[tuple[str, str]] = set()
351
458
  for compliance_item in self.failed_compliance_items:
352
459
  if not isinstance(compliance_item, WizComplianceItem):
353
- finding = super().create_finding_from_compliance_item(compliance_item)
354
- if finding:
355
- yield finding
356
460
  continue
357
461
 
358
462
  asset_id = (compliance_item.resource_id or "").lower()
359
- control = (compliance_item.control_id or "").upper()
360
- if not asset_id or not control:
463
+ if not asset_id:
361
464
  continue
362
465
 
363
- key = (asset_id, control)
364
- if key in seen_keys:
466
+ # Get ALL control IDs that this policy assessment maps to
467
+ all_control_ids = self._get_all_control_ids_for_compliance_item(compliance_item)
468
+ if not all_control_ids:
365
469
  continue
366
- seen_keys.add(key)
367
470
 
368
- finding = self.create_finding_from_compliance_item(compliance_item)
471
+ # Add this resource to each control it fails
472
+ for control_id in all_control_ids:
473
+ control = control_id.upper()
474
+
475
+ if control not in control_to_resources:
476
+ control_to_resources[control] = {}
477
+
478
+ # Use the first compliance item we find for this resource-control pair
479
+ # (there might be duplicates from multiple policy assessments)
480
+ if asset_id not in control_to_resources[control]:
481
+ control_to_resources[control][asset_id] = compliance_item
482
+
483
+ return control_to_resources
484
+
485
+ def _create_consolidated_findings(
486
+ self, control_to_resources: Dict[str, Dict[str, WizComplianceItem]]
487
+ ) -> Iterator[IntegrationFinding]:
488
+ """
489
+ Create consolidated findings from grouped control-resource mappings.
490
+
491
+ :param Dict[str, Dict[str, WizComplianceItem]] control_to_resources: Control groupings
492
+ :yield: Consolidated findings
493
+ :rtype: Iterator[IntegrationFinding]
494
+ """
495
+ for control_id, resources in control_to_resources.items():
496
+
497
+ # Use the first compliance item as the base for this control's finding
498
+ base_compliance_item = next(iter(resources.values()))
499
+
500
+ # Create a consolidated finding for this control
501
+ finding = self._create_consolidated_finding_for_control(
502
+ control_id=control_id, compliance_item=base_compliance_item, affected_resources=list(resources.keys())
503
+ )
504
+
369
505
  if finding:
370
506
  yield finding
371
507
 
508
+ def _create_consolidated_finding_for_control(
509
+ self, control_id: str, compliance_item: WizComplianceItem, affected_resources: List[str]
510
+ ) -> Optional[IntegrationFinding]:
511
+ """
512
+ Create a consolidated finding for a control with all affected resources.
513
+
514
+ :param str control_id: The control ID (e.g., 'AC-4(2)')
515
+ :param WizComplianceItem compliance_item: Base compliance item for this control
516
+ :param List[str] affected_resources: List of Wiz resource IDs that fail this control
517
+ :return: Consolidated finding with all affected resources
518
+ :rtype: Optional[IntegrationFinding]
519
+ """
520
+ # Filter to only resources that exist as assets in RegScale
521
+ asset_mappings = self._build_asset_mappings(affected_resources)
522
+
523
+ if not asset_mappings:
524
+ return None
525
+
526
+ # Create the base finding using the control-specific approach
527
+ finding = self._create_finding_for_specific_control(compliance_item, control_id)
528
+ if not finding:
529
+ return None
530
+
531
+ # Update the asset identifier and description with consolidated info
532
+ self._update_finding_with_consolidated_assets(finding, asset_mappings)
533
+ return finding
534
+
535
+ def _build_asset_mappings(self, resource_ids: List[str]) -> Dict[str, Dict[str, str]]:
536
+ """
537
+ Build asset mappings for resources that exist in RegScale.
538
+
539
+ :param List[str] resource_ids: List of Wiz resource IDs
540
+ :return: Mapping of resource IDs to asset information
541
+ :rtype: Dict[str, Dict[str, str]]
542
+ """
543
+ asset_mappings = {}
544
+
545
+ for resource_id in resource_ids:
546
+ if self._asset_exists_in_regscale(resource_id):
547
+ asset = self.get_asset_by_identifier(resource_id)
548
+ if asset and asset.name:
549
+ asset_mappings[resource_id] = {"name": asset.name, "wiz_id": resource_id}
550
+ else:
551
+ # Fallback to resource ID if asset name not found
552
+ asset_mappings[resource_id] = {"name": resource_id, "wiz_id": resource_id}
553
+
554
+ return asset_mappings
555
+
556
+ def _update_finding_with_consolidated_assets(
557
+ self, finding: IntegrationFinding, asset_mappings: Dict[str, Dict[str, str]]
558
+ ) -> None:
559
+ """
560
+ Update a finding with consolidated asset information.
561
+
562
+ :param IntegrationFinding finding: Finding to update
563
+ :param Dict[str, Dict[str, str]] asset_mappings: Asset mapping information
564
+ :return: None
565
+ :rtype: None
566
+ """
567
+ # Update the asset identifier to include all asset names (clean format for POAMs)
568
+ consolidated_asset_identifier = self._create_consolidated_asset_identifier(asset_mappings)
569
+ finding.asset_identifier = consolidated_asset_identifier
570
+
571
+ # Update finding description to indicate multiple resources
572
+ asset_names = [info["name"] for info in asset_mappings.values()]
573
+ if len(asset_names) > 1:
574
+ finding.description = f"{finding.description}\n\nThis control failure affects {len(asset_names)} assets: {', '.join(asset_names[:MAX_DISPLAY_ASSETS])}"
575
+ if len(asset_names) > MAX_DISPLAY_ASSETS:
576
+ finding.description += f" (and {len(asset_names) - MAX_DISPLAY_ASSETS} more)"
577
+
578
+ def _create_finding_for_specific_control(
579
+ self, compliance_item: WizComplianceItem, control_id: str
580
+ ) -> Optional[IntegrationFinding]:
581
+ """
582
+ Create a finding for a specific control ID from a compliance item.
583
+
584
+ This is similar to create_finding_from_compliance_item but ensures the finding
585
+ uses the specific control ID rather than just the first one.
586
+
587
+ :param WizComplianceItem compliance_item: Source compliance item
588
+ :param str control_id: Specific control ID to create finding for
589
+ :return: Integration finding for this specific control
590
+ :rtype: Optional[IntegrationFinding]
591
+ """
592
+ try:
593
+ control_labels = [control_id] if control_id else []
594
+ severity = self._map_severity(compliance_item.severity)
595
+ policy_name = self._get_policy_name(compliance_item)
596
+ title = f"{policy_name} ({control_id})" if control_id else policy_name
597
+ description = self._compose_description(policy_name, compliance_item)
598
+
599
+ finding = self._build_finding(
600
+ control_labels=control_labels,
601
+ title=title,
602
+ description=description,
603
+ severity=severity,
604
+ compliance_item=compliance_item,
605
+ )
606
+
607
+ # Set the specific control ID for this finding
608
+ finding.rule_id = control_id
609
+ finding.affected_controls = self._normalize_control_id_string(control_id)
610
+
611
+ # Ensure unique external_id for each control to prevent unwanted updates
612
+ finding.external_id = f"wiz-policy-control-{control_id.upper()}-{self.framework_id}"
613
+
614
+ self._set_assessment_id_if_available(finding, compliance_item)
615
+ return finding
616
+
617
+ except Exception as e:
618
+ logger.error(f"Error creating finding for control {control_id}: {e}")
619
+ return None
620
+
621
+ def _asset_exists_in_regscale(self, resource_id: str) -> bool:
622
+ """
623
+ Check if an asset with the given Wiz resource ID exists in RegScale.
624
+
625
+ :param str resource_id: Wiz resource ID to check (stored in RegScale asset wizId field)
626
+ :return: True if asset exists, False otherwise
627
+ :rtype: bool
628
+ """
629
+ if not resource_id:
630
+ return False
631
+
632
+ try:
633
+ # Check if we have a cached lookup of existing assets
634
+ if not hasattr(self, "_regscale_assets_by_wiz_id"):
635
+ self._load_regscale_assets()
636
+
637
+ return resource_id in self._regscale_assets_by_wiz_id
638
+ except Exception:
639
+ return False
640
+
641
+ def _load_regscale_assets(self) -> None:
642
+ """
643
+ Load all existing assets from RegScale into a Wiz ID-based lookup cache.
644
+ Wiz resource IDs are stored in the RegScale asset wizId field.
645
+ """
646
+ try:
647
+ logger.info("Loading existing assets from RegScale for asset existence checks...")
648
+ # Get all assets for the current plan
649
+ existing_assets = regscale_models.Asset.get_all_by_parent(
650
+ parent_id=self.plan_id,
651
+ parent_module=self.parent_module,
652
+ )
653
+
654
+ # Create Wiz ID-based lookup cache (Wiz resource ID -> RegScale asset)
655
+ self._regscale_assets_by_wiz_id = {asset.wizId: asset for asset in existing_assets if asset.wizId}
656
+ logger.info(f"Loaded {len(self._regscale_assets_by_wiz_id)} existing assets for lookup")
657
+
658
+ except Exception as e:
659
+ logger.error(f"Error loading RegScale assets: {e}")
660
+ # Initialize empty cache to avoid repeated failures
661
+ self._regscale_assets_by_wiz_id = {}
662
+
372
663
  def _map_framework_id_to_name(self, framework_id: str) -> str:
373
664
  """
374
665
  Map framework ID to framework name.
@@ -531,7 +822,7 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
531
822
  first_seen=self.scan_date,
532
823
  last_seen=self.scan_date,
533
824
  scan_date=self.scan_date,
534
- asset_identifier=compliance_item.resource_id,
825
+ asset_identifier=self._get_regscale_asset_identifier(compliance_item),
535
826
  vulnerability_type="Policy Compliance Violation",
536
827
  rule_id=compliance_item.control_id,
537
828
  baseline=compliance_item.framework,
@@ -567,9 +858,8 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
567
858
  assess = self._assessment_by_impl_today.get(impl_id)
568
859
  if assess:
569
860
  finding.assessment_id = assess.id
570
- logger.debug(f"Set finding.assessment_id = {assess.id} for control '{ctrl_norm}'")
571
- except Exception as e:
572
- logger.debug(f"Error setting finding assessment ID: {e}")
861
+ except Exception:
862
+ pass
573
863
 
574
864
  def create_asset_from_compliance_item(self, compliance_item: ComplianceItem) -> Optional[IntegrationAsset]:
575
865
  """
@@ -616,7 +906,7 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
616
906
 
617
907
  asset = IntegrationAsset(
618
908
  name=compliance_item.resource_name,
619
- identifier=compliance_item.resource_id,
909
+ identifier=f"{compliance_item.resource_name} ({compliance_item.resource_id})",
620
910
  external_id=compliance_item.resource_id,
621
911
  other_tracking_number=compliance_item.resource_id, # For deduplication
622
912
  asset_type=asset_type,
@@ -640,8 +930,8 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
640
930
  return None
641
931
 
642
932
  def create_scan_history(self): # type: ignore[override]
643
- """Create or reuse scan history using base behavior."""
644
- return super().create_scan_history()
933
+ """No scan history created for compliance report ingest."""
934
+ return None
645
935
 
646
936
  def _create_asset_notes(self, compliance_item: WizComplianceItem) -> str:
647
937
  """
@@ -741,12 +1031,11 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
741
1031
  # Try cache first unless forced refresh
742
1032
  cached_nodes = self._load_assessments_from_cache()
743
1033
  if cached_nodes is not None:
744
- logger.info(f"Using cached Wiz policy assessments ({len(cached_nodes)})")
1034
+ logger.info("Using cached Wiz policy assessments")
745
1035
  return cached_nodes
746
1036
 
747
1037
  # Only include variables supported by the query (avoid validation errors)
748
1038
  page_size = 100
749
- logger.info(f"Using Wiz policy assessments page size (first): {page_size}")
750
1039
  base_variables = {"first": page_size}
751
1040
 
752
1041
  # Try multiple filter key variants to avoid schema differences across tenants
@@ -768,7 +1057,7 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
768
1057
  total=1,
769
1058
  )
770
1059
  results = run_async_queries(
771
- endpoint=self.wiz_endpoint or "https://api.wiz.io/graphql",
1060
+ endpoint=self.wiz_endpoint or WIZ_URL,
772
1061
  headers=headers,
773
1062
  query_configs=[
774
1063
  {
@@ -863,7 +1152,7 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
863
1152
  # If endpoint is not set (tests), short-circuit to async path mock
864
1153
  if not self.wiz_endpoint:
865
1154
  results = run_async_queries(
866
- endpoint="https://api.wiz.io/graphql",
1155
+ endpoint=WIZ_URL,
867
1156
  headers=headers,
868
1157
  query_configs=[
869
1158
  {
@@ -892,7 +1181,6 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
892
1181
  )
893
1182
  except Exception as exc: # noqa: BLE001 - propagate last error
894
1183
  last_error = exc
895
- logger.debug(f"Filter variant {fv} failed: {exc}")
896
1184
 
897
1185
  msg = f"Failed to fetch policy assessments after trying all filter variants: {last_error}"
898
1186
  logger.error(msg)
@@ -929,9 +1217,7 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
929
1217
  variant_name = self._variant_name(filter_variant)
930
1218
  progress.update(
931
1219
  task,
932
- description=(
933
- f"[#f68d1f]Fetching Wiz policy assessments (limit: {page_size}, " f"variant: {variant_name})..."
934
- ),
1220
+ description=(f"[#f68d1f]Fetching Wiz policy assessments (limit: {page_size}, variant: {variant_name})..."),
935
1221
  advance=1,
936
1222
  )
937
1223
 
@@ -957,7 +1243,8 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
957
1243
  completed=1,
958
1244
  total=1,
959
1245
  )
960
- logger.info(f"Successfully fetched {len(filtered_nodes)} policy assessments")
1246
+ logger.info("Successfully fetched Wiz policy assessments")
1247
+
961
1248
  return filtered_nodes
962
1249
 
963
1250
  def _execute_wiz_policy_query_paginated(
@@ -1158,12 +1445,10 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1158
1445
  for control_id, ctrl in control_agg.items():
1159
1446
  jf.write(json.dumps(ctrl, ensure_ascii=False) + "\n")
1160
1447
  logger.info(f"Policy compliance JSONL written to: {file_path_jsonl}")
1161
- # Best-effort cleanup to keep artifacts directory tidy
1162
- self._cleanup_artifacts(artifacts_dir, keep=5)
1448
+ self._cleanup_artifacts(artifacts_dir, keep=CACHE_CLEANUP_KEEP_COUNT)
1163
1449
  return file_path
1164
1450
 
1165
1451
  except Exception as e:
1166
- logger.error(f"Failed to write policy data to JSON: {str(e)}")
1167
1452
  error_and_exit(f"Failed to write policy data to JSON: {str(e)}")
1168
1453
 
1169
1454
  def _build_control_aggregation(self) -> Dict[str, Dict[str, Any]]:
@@ -1300,7 +1585,7 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1300
1585
  logger.error(f"Error parsing JSONL {jsonl_path}: {exc}")
1301
1586
  return aggregated
1302
1587
 
1303
- def _cleanup_artifacts(self, dir_path: str, keep: int = 5) -> None:
1588
+ def _cleanup_artifacts(self, dir_path: str, keep: int = CACHE_CLEANUP_KEEP_COUNT) -> None:
1304
1589
  """
1305
1590
  Keep the most recent JSON and JSONL policy_compliance_report files, delete older ones.
1306
1591
 
@@ -1330,8 +1615,8 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1330
1615
  except Exception:
1331
1616
  # Non-fatal; continue cleanup
1332
1617
  pass
1333
- except Exception as e:
1334
- logger.debug(f"Artifact cleanup skipped: {e}")
1618
+ except Exception:
1619
+ pass
1335
1620
 
1336
1621
  def load_or_create_framework_mapping(self) -> Dict[str, str]:
1337
1622
  """
@@ -1439,7 +1724,6 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1439
1724
  return nodes
1440
1725
 
1441
1726
  except Exception as e:
1442
- logger.error(f"Failed to fetch security frameworks: {str(e)}")
1443
1727
  error_and_exit(f"Failed to fetch security frameworks: {str(e)}")
1444
1728
 
1445
1729
  def _create_framework_mapping(self, frameworks: List[Dict[str, Any]]) -> Dict[str, str]:
@@ -1515,6 +1799,45 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1515
1799
 
1516
1800
  return self.framework_mapping.get(framework_id, framework_id)
1517
1801
 
1802
+ def sync_compliance(self) -> None:
1803
+ """
1804
+ Override base sync_compliance to ensure proper order for controlId/assessmentId assignment.
1805
+
1806
+ CRITICAL: Control assessments MUST be created BEFORE issues are processed
1807
+ to ensure controlId and assessmentId can be properly set.
1808
+ """
1809
+ logger.info(f"Starting {self.title} compliance sync with proper assessment ordering...")
1810
+
1811
+ try:
1812
+ scan_history = self.create_scan_history()
1813
+ self.process_compliance_data()
1814
+
1815
+ # Step 1: Sync assets first
1816
+ self._sync_assets()
1817
+
1818
+ # Step 2: CRITICAL - Pre-populate control implementation cache BEFORE creating assessments
1819
+ logger.info("🔧 Pre-populating control implementation cache for issue processing...")
1820
+ self._populate_control_implementation_cache()
1821
+
1822
+ # Step 3: Create control assessments BEFORE issues (ensures assessmentId is available)
1823
+ logger.info("🔧 Creating control assessments BEFORE issue processing...")
1824
+ self._sync_control_assessments()
1825
+
1826
+ # Step 3.5: CRITICAL - Refresh assessment cache after assessments are created
1827
+ logger.info("🔧 Refreshing assessment cache with newly created assessments...")
1828
+ self._refresh_assessment_cache_after_creation()
1829
+
1830
+ # Step 4: NOW process issues with controlId and assessmentId properly set
1831
+ logger.info("🔧 Processing issues with control and assessment IDs available...")
1832
+ self._sync_issues()
1833
+
1834
+ self._finalize_scan_history(scan_history)
1835
+
1836
+ logger.info(f"Completed {self.title} compliance sync with proper assessment ordering")
1837
+
1838
+ except Exception as e:
1839
+ error_and_exit(f"Error during compliance sync: {e}")
1840
+
1518
1841
  def sync_policy_compliance(self, create_issues: bool = None, update_control_status: bool = None) -> None:
1519
1842
  """
1520
1843
  Main method to sync policy compliance data from Wiz.
@@ -1543,8 +1866,11 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1543
1866
  if update_control_status is not None:
1544
1867
  self.update_control_status = update_control_status
1545
1868
 
1546
- # Step 3: Process and sync using the base class
1547
- self.process_compliance_data()
1869
+ # Step 3: Sync using the overridden method (which ensures proper ordering)
1870
+ logger.info(
1871
+ f"🔧 Sync parameters: create_issues={self.create_issues}, update_control_status={self.update_control_status}"
1872
+ )
1873
+
1548
1874
  self.sync_compliance()
1549
1875
 
1550
1876
  # Step 4: Write data to JSON file for reference (post-processing)
@@ -1554,7 +1880,6 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1554
1880
  logger.info("Policy compliance sync completed successfully")
1555
1881
 
1556
1882
  except Exception as e:
1557
- logger.error(f"Policy compliance sync failed: {str(e)}")
1558
1883
  error_and_exit(f"Policy compliance sync failed: {str(e)}")
1559
1884
 
1560
1885
  def sync_wiz_compliance(self) -> None:
@@ -1587,180 +1912,1054 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1587
1912
  finding: IntegrationFinding,
1588
1913
  ) -> regscale_models.Issue:
1589
1914
  """
1590
- Create/update the issue, then set it as a child of the asset and attach affected controls and remediation.
1915
+ Create/update the issue with ALL fields set BEFORE saving.
1916
+
1917
+ This method ensures proper data flow:
1918
+ 1. Check for existing issues to prevent duplicates
1919
+ 2. Pre-populate compliance fields on the finding
1920
+ 3. Use parent class logic which saves with all fields set
1591
1921
 
1592
- - Parent the issue to the asset (parentId=asset.id, parentModule='assets')
1593
- - Populate affectedControls with all failed control IDs for the asset
1594
- - Ensure remediationDescription contains Wiz remediationInstructions
1922
+ This fixes the duplicate issue creation problem by using proper
1923
+ duplicate detection and avoids double-saving.
1595
1924
  """
1596
- # Defer to base to handle dedupe and asset identifier consolidation (newline-delimited)
1597
- # The base class will now automatically handle finding.assessment_id -> issue.assessmentId
1598
- issue = super().create_or_update_issue_from_finding(title, finding)
1925
+ # Load cache if not already loaded for duplicate detection
1926
+ self._load_existing_records_cache()
1599
1927
 
1600
- # Post-processing for compliance-specific fields
1601
- try:
1602
- self._update_issue_affected_controls(issue, finding)
1603
- issue.assetIdentifier = self._compute_consolidated_asset_identifier(issue, finding)
1604
- self._set_control_and_assessment_ids(issue, finding)
1605
- if getattr(self, "create_poams", False):
1606
- issue.isPoam = True
1607
- self._reparent_issue_to_asset(issue, finding)
1608
- issue.save(bulk=True)
1609
- except Exception as e:
1610
- logger.error(f"Error in post-issue processing: {e}")
1611
- import traceback
1928
+ # CRITICAL: Pre-populate compliance fields on the finding BEFORE parent call
1929
+ # This ensures the parent class saves the issue with all fields already set
1930
+ self._populate_compliance_fields_on_finding(finding)
1612
1931
 
1613
- logger.debug(traceback.format_exc())
1932
+ # CRITICAL FIX: If assessment_id is set, prepare the finding for assessment parenting
1933
+ if hasattr(finding, "assessment_id") and finding.assessment_id:
1934
+ assessment_id = finding.assessment_id
1935
+ logger.debug(f"🔄 PRE-SETTING ASSESSMENT PARENT: assessmentId={assessment_id}")
1614
1936
 
1615
- return issue
1937
+ # Add parent override fields to the finding for the ScannerIntegration to use
1938
+ finding._override_parent_id = assessment_id
1939
+ finding._override_parent_module = "assessments"
1616
1940
 
1617
- # -------- Helpers to reduce complexity --------
1618
- def _update_issue_affected_controls(self, issue: regscale_models.Issue, finding: IntegrationFinding) -> None:
1941
+ logger.debug(f" ✅ Finding will use parent: assessments #{assessment_id}")
1942
+
1943
+ # Check for existing issue by external_id first
1944
+ external_id = finding.external_id
1945
+ existing_issue = self._find_existing_issue_cached(external_id)
1946
+
1947
+ if existing_issue:
1948
+ return self._update_existing_issue_with_compliance_fields(existing_issue, title, finding)
1949
+ else:
1950
+ # Set finding context for our override method to access
1951
+ self._current_finding_context = finding
1952
+ try:
1953
+ # Parent class will now create/save the issue with compliance fields already set
1954
+ return super().create_or_update_issue_from_finding(title, finding)
1955
+ finally:
1956
+ # Clean up context
1957
+ if hasattr(self, "_current_finding_context"):
1958
+ delattr(self, "_current_finding_context")
1959
+
1960
+ def _update_existing_issue_with_compliance_fields(
1961
+ self, existing_issue: regscale_models.Issue, title: str, finding: IntegrationFinding
1962
+ ) -> regscale_models.Issue:
1619
1963
  """
1620
- Update the affected controls field on an issue from a finding.
1964
+ Update existing issue with basic fields and enhance with compliance-specific fields.
1621
1965
 
1622
- :param regscale_models.Issue issue: Issue to update
1623
- :param IntegrationFinding finding: Finding with control information
1624
- :return: None
1625
- :rtype: None
1966
+ :param existing_issue: The existing issue to update
1967
+ :param title: New issue title
1968
+ :param finding: Finding with updated data
1969
+ :return: Updated issue with all fields set
1970
+ """
1971
+
1972
+ # Update basic fields (similar to parent class logic)
1973
+ existing_issue.title = title
1974
+ existing_issue.description = finding.description
1975
+ existing_issue.severity = finding.severity
1976
+ existing_issue.status = finding.status
1977
+ existing_issue.dateLastUpdated = self.scan_date
1978
+
1979
+ # Set control-related field
1980
+ if getattr(finding, "control_labels", None):
1981
+ existing_issue.affectedControls = ",".join(finding.control_labels)
1982
+ elif getattr(finding, "affected_controls", None):
1983
+ existing_issue.affectedControls = finding.affected_controls
1984
+
1985
+ # Enhance with compliance-specific fields
1986
+ self._enhance_issue_with_compliance_fields(existing_issue, finding)
1987
+
1988
+ # CRITICAL FIX: Handle assessment parenting for existing issues too
1989
+ if hasattr(finding, "assessment_id") and finding.assessment_id:
1990
+ assessment_id = finding.assessment_id
1991
+
1992
+ # Set assessment as the parent
1993
+ existing_issue.parentId = assessment_id
1994
+ existing_issue.parentModule = "assessments"
1995
+ existing_issue.assessmentId = assessment_id
1996
+
1997
+ existing_issue.save()
1998
+
1999
+ return existing_issue
2000
+
2001
+ def _create_or_update_issue(
2002
+ self,
2003
+ finding: IntegrationFinding,
2004
+ issue_status,
2005
+ title: str,
2006
+ existing_issue=None,
2007
+ ):
2008
+ """
2009
+ Override parent method to handle assessment parenting correctly.
2010
+
2011
+ CRITICAL FIX: Check if the finding has assessment parent overrides and apply them.
1626
2012
  """
1627
- if getattr(finding, "affected_controls", None):
2013
+ # Get consolidated asset identifier
2014
+ asset_identifier = self.get_consolidated_asset_identifier(finding, existing_issue)
2015
+
2016
+ # Prepare issue data
2017
+ issue_title = self.get_issue_title(finding) or title
2018
+ description = finding.description or ""
2019
+ remediation_description = finding.recommendation_for_mitigation or finding.remediation or ""
2020
+ is_poam = self.is_poam(finding)
2021
+
2022
+ if existing_issue:
2023
+ logger.debug(
2024
+ "Updating existing issue %s with assetIdentifier %s", existing_issue.id, finding.asset_identifier
2025
+ )
2026
+
2027
+ # If we have an existing issue, update its fields instead of creating a new one
2028
+ issue = existing_issue or regscale_models.Issue()
2029
+
2030
+ # CRITICAL FIX: Check for parent overrides from the finding
2031
+ if hasattr(finding, "_override_parent_id") and hasattr(finding, "_override_parent_module"):
2032
+ parent_id = finding._override_parent_id
2033
+ parent_module = finding._override_parent_module
2034
+ logger.debug(f"🔄 USING OVERRIDE PARENT: {parent_module} #{parent_id}")
2035
+ else:
2036
+ parent_id = self.plan_id
2037
+ parent_module = self.parent_module
2038
+
2039
+ # Update all fields (copying from ScannerIntegration but with override parent)
2040
+ issue.parentId = parent_id
2041
+ issue.parentModule = parent_module
2042
+ issue.vulnerabilityId = finding.vulnerability_id
2043
+ issue.title = issue_title
2044
+ issue.dateCreated = finding.date_created
2045
+ issue.status = issue_status
2046
+ issue.dateCompleted = (
2047
+ self.get_date_completed(finding, issue_status)
2048
+ if issue_status == regscale_models.IssueStatus.Closed
2049
+ else None
2050
+ )
2051
+ issue.severityLevel = finding.severity
2052
+ issue.issueOwnerId = self.assessor_id
2053
+ issue.securityPlanId = self.plan_id if not self.is_component else None
2054
+ issue.identification = finding.identification
2055
+ issue.dateFirstDetected = finding.first_seen
2056
+
2057
+ # Ensure a due date is always set using configured policy defaults (e.g., FedRAMP)
2058
+ if not finding.due_date:
2059
+ try:
2060
+ base_created = finding.date_created or issue.dateCreated
2061
+ finding.due_date = issue_due_date(
2062
+ severity=finding.severity,
2063
+ created_date=base_created,
2064
+ title=self.title,
2065
+ )
2066
+ except Exception:
2067
+ # Final fallback to a Low severity default if anything goes wrong
2068
+ base_created = finding.date_created or issue.dateCreated
2069
+ finding.due_date = issue_due_date(
2070
+ severity=regscale_models.IssueSeverity.Low,
2071
+ created_date=base_created,
2072
+ title=self.title,
2073
+ )
2074
+ issue.dueDate = finding.due_date
2075
+ issue.description = description
2076
+ issue.sourceReport = finding.source_report or self.title
2077
+ issue.recommendedActions = finding.recommendation_for_mitigation
2078
+ issue.assetIdentifier = asset_identifier
2079
+ issue.securityChecks = finding.security_check or finding.external_id
2080
+ issue.remediationDescription = remediation_description
2081
+ issue.integrationFindingId = self.get_finding_identifier(finding)
2082
+ issue.poamComments = finding.poam_comments
2083
+ issue.cve = finding.cve
2084
+
2085
+ # CRITICAL: Set assessmentId (this is the key fix)
2086
+ issue.assessmentId = finding.assessment_id
2087
+ logger.debug(f"✅ SETTING assessmentId = {finding.assessment_id} with parent = {parent_module} #{parent_id}")
2088
+
2089
+ control_id = self.get_control_implementation_id_for_cci(finding.cci_ref) if finding.cci_ref else None
2090
+ issue.controlId = control_id
2091
+
2092
+ # Add the control implementation ids and the cci ref if it exists
2093
+ cci_control_ids = [control_id] if control_id is not None else []
2094
+ if finding.affected_controls:
1628
2095
  issue.affectedControls = finding.affected_controls
1629
- elif getattr(finding, "control_labels", None):
1630
- issue.affectedControls = ",".join(finding.control_labels)
2096
+ elif finding.control_labels:
2097
+ issue.affectedControls = ", ".join(sorted({cl for cl in finding.control_labels if cl}))
2098
+
2099
+ issue.controlImplementationIds = list(set(finding._control_implementation_ids + cci_control_ids)) # noqa
2100
+ issue.isPoam = is_poam
2101
+ issue.basisForAdjustment = (
2102
+ finding.basis_for_adjustment if finding.basis_for_adjustment else f"{self.title} import"
2103
+ )
2104
+ issue.pluginId = finding.plugin_id
2105
+ issue.originalRiskRating = regscale_models.Issue.assign_risk_rating(finding.severity)
2106
+ issue.changes = "<p>Current: {}</p><p>Planned: {}</p>".format(
2107
+ finding.milestone_changes, finding.planned_milestone_changes
2108
+ )
2109
+ issue.adjustedRiskRating = finding.adjusted_risk_rating
2110
+ issue.riskAdjustment = finding.risk_adjustment
2111
+ issue.operationalRequirement = finding.operational_requirements
2112
+ issue.deviationRationale = finding.deviation_rationale
2113
+ issue.dateLastUpdated = get_current_datetime()
2114
+ issue.affectedControls = finding.affected_controls
2115
+
2116
+ if finding.cve:
2117
+ issue = self.lookup_kev_and_update_issue(cve=finding.cve, issue=issue, cisa_kevs=self._kev_data)
2118
+
2119
+ if existing_issue:
2120
+ logger.debug(f"💾 Saving existing issue {issue.id} with assessmentId={issue.assessmentId}")
2121
+ issue.save(bulk=True)
2122
+ else:
2123
+ logger.info(f"💾 Creating new issue with assessmentId={issue.assessmentId}")
2124
+ issue = issue.create_or_update(
2125
+ bulk_update=True, defaults={"otherIdentifier": self._get_other_identifier(finding, is_poam)}
2126
+ )
2127
+ self.extra_data_to_properties(finding, issue.id)
2128
+
2129
+ self._handle_property_and_milestone_creation(issue, finding, existing_issue)
2130
+ return issue
1631
2131
 
1632
- def _compute_consolidated_asset_identifier(self, issue: regscale_models.Issue, finding: IntegrationFinding) -> str:
2132
+ def _populate_compliance_fields_on_finding(self, finding: IntegrationFinding) -> None:
1633
2133
  """
1634
- Compute a consolidated asset identifier list for an issue.
2134
+ Pre-populate compliance-specific fields on the finding before issue creation.
1635
2135
 
1636
- Aggregates all affected asset identifiers for the same control into a newline-delimited string.
2136
+ This ensures controlId and assessmentId are set on the finding object
2137
+ so the parent class can save the issue with all fields in one operation.
1637
2138
 
1638
- :param regscale_models.Issue issue: Issue to consolidate identifiers for
1639
- :param IntegrationFinding finding: Finding with asset information
1640
- :return: Newline-delimited string of asset identifiers
1641
- :rtype: str
2139
+ The parent class expects:
2140
+ - finding.assessment_id -> issue.assessmentId
2141
+ - finding.cci_ref -> calls get_control_implementation_id_for_cci() -> issue.controlId
2142
+
2143
+ :param finding: Finding to populate with compliance fields
1642
2144
  """
1643
- delimiter = "\n"
1644
- identifiers: set[str] = set()
1645
- # Collect identifiers from all failed items matching this control
1646
2145
  try:
1647
- normalized_rule = self._normalize_control_id_string(finding.rule_id)
1648
- for item in self.failed_compliance_items:
1649
- try:
1650
- item_ctrl = self._normalize_control_id_string(getattr(item, "control_id", None))
1651
- res_id = getattr(item, "resource_id", None)
1652
- if normalized_rule and item_ctrl == normalized_rule and res_id:
1653
- identifiers.add(res_id)
1654
- except Exception:
1655
- continue
2146
+ # Set compliance fields on the finding itself before issue creation
2147
+ if hasattr(finding, "rule_id") and finding.rule_id:
2148
+ control_id = self._normalize_control_id_string(finding.rule_id)
2149
+ if control_id:
2150
+
2151
+ # Get control implementation ID
2152
+ impl_id = self._issue_field_setter._get_or_find_implementation_id(control_id)
2153
+ if impl_id:
2154
+ # Store the control ID as cci_ref so parent class calls our override method
2155
+ finding.cci_ref = control_id
2156
+ # Cache the implementation ID for our override method
2157
+ finding._wiz_control_implementation_id = impl_id
2158
+
2159
+ # Get assessment ID and set it on the finding (parent class uses this directly)
2160
+ assess_id = self._issue_field_setter._get_or_find_assessment_id(impl_id)
2161
+ if assess_id:
2162
+ finding.assessment_id = assess_id
1656
2163
  except Exception:
1657
2164
  pass
1658
- # Merge with existing identifiers and current finding
1659
- if issue.assetIdentifier:
1660
- identifiers |= {e for e in (issue.assetIdentifier or "").split(delimiter) if e}
1661
- if finding.asset_identifier:
1662
- identifiers.add(finding.asset_identifier)
1663
- return delimiter.join(sorted(identifiers))
1664
2165
 
1665
- def _set_control_and_assessment_ids(self, issue: regscale_models.Issue, finding: IntegrationFinding) -> None:
2166
+ def _enhance_issue_with_compliance_fields(self, issue: regscale_models.Issue, finding: IntegrationFinding) -> None:
1666
2167
  """
1667
- Set control implementation and assessment IDs on an issue.
2168
+ Enhance an issue with compliance-specific fields (controlId and assessmentId).
1668
2169
 
1669
- :param regscale_models.Issue issue: Issue to update
1670
- :param IntegrationFinding finding: Finding with control information
1671
- :return: None
1672
- :rtype: None
2170
+ NOTE: This method is now primarily for the existing issue update path.
2171
+ New issues should have fields set via _populate_compliance_fields_on_finding.
2172
+
2173
+ :param issue: Issue object to enhance
2174
+ :param finding: Finding with control data
1673
2175
  """
1674
2176
  try:
1675
- ctrl_norm = self._normalize_control_id_string(finding.rule_id)
1676
- impl_id = None
1677
- if ctrl_norm and hasattr(self, "_impl_id_by_control"):
1678
- impl_id = self._impl_id_by_control.get(ctrl_norm)
1679
- if impl_id:
1680
- issue.controlId = impl_id
1681
- assess_id = getattr(finding, "assessment_id", None)
1682
- if not assess_id and impl_id and hasattr(self, "_assessment_by_impl_today"):
1683
- assess = self._assessment_by_impl_today.get(impl_id)
1684
- assess_id = assess.id if assess else None
1685
- if assess_id:
1686
- issue.assessmentId = assess_id
2177
+ # Set control implementation and assessment IDs using our field setter
2178
+ if hasattr(finding, "rule_id") and finding.rule_id:
2179
+ control_id = self._normalize_control_id_string(finding.rule_id)
2180
+ if control_id:
2181
+ result = self._issue_field_setter.set_control_and_assessment_ids(issue, control_id)
2182
+ if not result.success:
2183
+ logger.warning(f"Failed to set compliance fields for '{control_id}': {result.error_message}")
1687
2184
  except Exception:
1688
2185
  pass
1689
2186
 
1690
- def _reparent_issue_to_asset(self, issue: regscale_models.Issue, finding: IntegrationFinding) -> None:
2187
+ def get_control_implementation_id_for_cci(self, cci: Optional[str]) -> Optional[int]:
2188
+ """
2189
+ Override parent method to return control implementation ID for Wiz control IDs.
2190
+
2191
+ The parent class calls this method when finding.cci_ref is set, and uses the
2192
+ returned value to set issue.controlId. We store our control implementation
2193
+ ID on the finding and return it here.
2194
+
2195
+ :param cci: Control identifier (e.g., 'AC-2(1)') stored in finding.cci_ref
2196
+ :return: Control implementation ID if found, None otherwise
1691
2197
  """
1692
- Reparent an issue to be a child of its associated asset.
2198
+ # Check if this is a call with our cached implementation ID on the current finding
2199
+ if hasattr(self, "_current_finding_context"):
2200
+ finding = self._current_finding_context
2201
+ if (
2202
+ hasattr(finding, "_wiz_control_implementation_id")
2203
+ and hasattr(finding, "cci_ref")
2204
+ and finding.cci_ref == cci
2205
+ ):
2206
+ impl_id = finding._wiz_control_implementation_id
2207
+ return impl_id
2208
+
2209
+ # Fallback: try to look it up directly (for edge cases)
2210
+ if cci:
2211
+ control_id = self._normalize_control_id_string(cci)
2212
+ if control_id:
2213
+ impl_id = self._issue_field_setter._get_or_find_implementation_id(control_id)
2214
+ if impl_id:
2215
+ return impl_id
2216
+
2217
+ # Final fallback to parent class behavior
2218
+ return super().get_control_implementation_id_for_cci(cci)
2219
+
2220
+ def _populate_control_implementation_cache(self) -> None:
2221
+ """
2222
+ Pre-populate the control implementation and assessment caches.
2223
+
2224
+ CRITICAL: This ensures controlId and assessmentId can be reliably set on issues.
2225
+ This method loads control implementations and their associated assessments into
2226
+ cache to enable fast lookups during issue processing.
1693
2227
 
1694
- :param regscale_models.Issue issue: Issue to reparent
1695
- :param IntegrationFinding finding: Finding with asset identifier
1696
2228
  :return: None
1697
2229
  :rtype: None
1698
2230
  """
1699
2231
  try:
1700
- asset = self.get_asset_by_identifier(finding.asset_identifier)
1701
- if not asset:
1702
- asset = self._ensure_asset_for_finding(finding)
1703
- if asset and getattr(asset, "id", None):
1704
- issue.parentId = asset.id
1705
- issue.parentModule = "assets"
1706
- except Exception:
1707
- # If asset lookup fails, keep existing parent
1708
- pass
2232
+ from regscale.models import regscale_models
1709
2233
 
1710
- def _update_scan_history(self, scan_history: regscale_models.ScanHistory) -> None:
2234
+ logger.info("🔍 Pre-populating control implementation cache for issue processing...")
2235
+
2236
+ # Get all control implementations for this plan
2237
+ implementations = regscale_models.ControlImplementation.get_all_by_parent(
2238
+ parent_id=self.plan_id, parent_module=self.parent_module
2239
+ )
2240
+
2241
+ if not implementations:
2242
+ logger.warning("No control implementations found for this plan")
2243
+ return
2244
+
2245
+ logger.info(f"Found {len(implementations)} control implementations to cache")
2246
+
2247
+ # Cache SecurityControl lookups to avoid repeated API calls
2248
+ security_control_cache = {}
2249
+ controls_mapped = 0
2250
+ assessments_mapped = 0
2251
+
2252
+ for impl in implementations:
2253
+ try:
2254
+ # Skip if no controlID reference
2255
+ if not hasattr(impl, "controlID") or not impl.controlID:
2256
+ continue
2257
+
2258
+ # Get or cache the security control
2259
+ if impl.controlID not in security_control_cache:
2260
+ security_control = regscale_models.SecurityControl.get_object(object_id=impl.controlID)
2261
+ security_control_cache[impl.controlID] = security_control
2262
+ else:
2263
+ security_control = security_control_cache[impl.controlID]
2264
+
2265
+ if security_control and hasattr(security_control, "controlId"):
2266
+ # Normalize and cache the control ID mapping
2267
+ normalized_id = self._normalize_control_id_string(security_control.controlId)
2268
+ if normalized_id:
2269
+ self._impl_id_by_control[normalized_id] = impl.id
2270
+ controls_mapped += 1
2271
+
2272
+ # Also try to cache the most recent assessment
2273
+ try:
2274
+ assessments = regscale_models.Assessment.get_all_by_parent(
2275
+ parent_id=impl.id, parent_module="controls"
2276
+ )
2277
+ if assessments:
2278
+ # Get the most recent assessment
2279
+ assessments.sort(key=lambda a: a.id if hasattr(a, "id") else 0, reverse=True)
2280
+ self._assessment_by_impl_today[impl.id] = assessments[0]
2281
+ assessments_mapped += 1
2282
+ except Exception:
2283
+ pass
2284
+
2285
+ except Exception:
2286
+ continue
2287
+
2288
+ logger.info("✓ Control implementation cache populated:")
2289
+ logger.info(f" - {controls_mapped} control ID mappings")
2290
+ logger.info(f" - {assessments_mapped} assessment mappings")
2291
+
2292
+ except Exception as e:
2293
+ logger.error(f"Error populating control implementation cache: {e}")
2294
+
2295
+ def _refresh_assessment_cache_after_creation(self) -> None:
1711
2296
  """
1712
- Update scan history with severity breakdown of deduped compliance issues.
2297
+ Refresh the assessment cache after control assessments have been created.
2298
+
2299
+ CRITICAL: This ensures that newly created assessments from the sync_control_assessments
2300
+ step are available when processing issues. Without this, assessmentId will not be set
2301
+ on issues because the cache only contains old assessments.
1713
2302
 
1714
- :param regscale_models.ScanHistory scan_history: Scan history record
2303
+ :return: None
2304
+ :rtype: None
1715
2305
  """
1716
2306
  try:
1717
- from regscale.core.app.utils.app_utils import get_current_datetime
2307
+ from regscale.models import regscale_models
2308
+ from datetime import datetime
2309
+
2310
+ logger.info("🔄 Refreshing assessment cache with newly created assessments...")
1718
2311
 
1719
- # Deduped pairs of (resource, canonical control)
1720
- seen_pairs: set[tuple[str, str]] = set()
1721
- severity_counts = {"Critical": 0, "High": 0, "Moderate": 0, "Low": 0}
2312
+ refreshed_count = 0
2313
+ today = datetime.now().date()
1722
2314
 
1723
- for it in self.failed_compliance_items:
2315
+ # Only refresh assessments for implementations we know about
2316
+ for control_id, impl_id in self._impl_id_by_control.items():
1724
2317
  try:
1725
- rid = (getattr(it, "resource_id", "") or "").lower()
1726
- ctrl_norm = self._normalize_control_id_string(getattr(it, "control_id", "")) or ""
1727
- if not rid or not ctrl_norm:
2318
+ # Get all assessments for this implementation
2319
+ assessments = regscale_models.Assessment.get_all_by_parent(
2320
+ parent_id=impl_id, parent_module="controls"
2321
+ )
2322
+
2323
+ if not assessments:
1728
2324
  continue
1729
- key = (rid, ctrl_norm)
1730
- if key in seen_pairs:
2325
+
2326
+ # Find today's assessment (most recent created today)
2327
+ today_assessments = []
2328
+ for assessment in assessments:
2329
+ assessment_date = None
2330
+ try:
2331
+ # Try to get assessment date from various fields
2332
+ date_fields = ["actualFinish", "plannedFinish", "dateCreated"]
2333
+ for field in date_fields:
2334
+ if hasattr(assessment, field) and getattr(assessment, field):
2335
+ date_value = getattr(assessment, field)
2336
+ if isinstance(date_value, str):
2337
+ from regscale.core.app.utils.app_utils import regscale_string_to_datetime
2338
+
2339
+ assessment_date = regscale_string_to_datetime(date_value).date()
2340
+ elif hasattr(date_value, "date"):
2341
+ assessment_date = date_value.date()
2342
+ else:
2343
+ assessment_date = date_value
2344
+ break
2345
+
2346
+ if assessment_date == today:
2347
+ today_assessments.append(assessment)
2348
+ except Exception:
2349
+ continue
2350
+
2351
+ # Use most recent today's assessment, or fallback to most recent overall
2352
+ if today_assessments:
2353
+ best_assessment = max(today_assessments, key=lambda a: getattr(a, "id", 0))
2354
+ else:
2355
+ best_assessment = max(assessments, key=lambda a: getattr(a, "id", 0))
2356
+
2357
+ # Update the cache
2358
+ self._assessment_by_impl_today[impl_id] = best_assessment
2359
+ refreshed_count += 1
2360
+
2361
+ except Exception:
2362
+ continue
2363
+
2364
+ logger.info(f"✓ Assessment cache refreshed: {refreshed_count} assessments updated")
2365
+
2366
+ except Exception as e:
2367
+ logger.error(f"Error refreshing assessment cache: {e}")
2368
+
2369
+ def _find_control_implementation_id(self, control_id: str) -> Optional[int]:
2370
+ """
2371
+ Find control implementation ID by querying the database directly.
2372
+ OPTIMIZED: Uses controlID field directly and caches SecurityControl lookups.
2373
+
2374
+ :param str control_id: Normalized control ID (e.g., 'AC-2(1)')
2375
+ :return: Control implementation ID if found
2376
+ :rtype: Optional[int]
2377
+ """
2378
+ try:
2379
+ from regscale.models import regscale_models
2380
+
2381
+ # First check cache
2382
+ if hasattr(self, "_impl_id_by_control") and control_id in self._impl_id_by_control:
2383
+ cached_id = self._impl_id_by_control[control_id]
2384
+ return cached_id
2385
+
2386
+ # Get all control implementations for this plan
2387
+ implementations = regscale_models.ControlImplementation.get_all_by_parent(
2388
+ parent_id=self.plan_id, parent_module=self.parent_module
2389
+ )
2390
+
2391
+ # Create a cache for SecurityControl lookups to avoid repeated API calls
2392
+ security_control_cache = {}
2393
+
2394
+ for impl in implementations:
2395
+ try:
2396
+ # Use controlID field which references the SecurityControl
2397
+ if not hasattr(impl, "controlID") or not impl.controlID:
1731
2398
  continue
1732
- seen_pairs.add(key)
1733
-
1734
- sev = self._map_severity(getattr(it, "severity", None))
1735
- if sev == regscale_models.IssueSeverity.Critical:
1736
- severity_counts["Critical"] += 1
1737
- elif sev == regscale_models.IssueSeverity.High:
1738
- severity_counts["High"] += 1
1739
- elif sev == regscale_models.IssueSeverity.Moderate:
1740
- severity_counts["Moderate"] += 1
2399
+
2400
+ # Check if we've already looked up this security control
2401
+ if impl.controlID not in security_control_cache:
2402
+ security_control = regscale_models.SecurityControl.get_object(object_id=impl.controlID)
2403
+ security_control_cache[impl.controlID] = security_control
1741
2404
  else:
1742
- severity_counts["Low"] += 1
2405
+ security_control = security_control_cache[impl.controlID]
2406
+
2407
+ if security_control and hasattr(security_control, "controlId"):
2408
+ impl_control_id = self._normalize_control_id_string(security_control.controlId)
2409
+
2410
+ if impl_control_id == control_id:
2411
+ logger.info(f"✓ Found control implementation {impl.id} for control {control_id}")
2412
+ # Cache it for future lookups
2413
+ if not hasattr(self, "_impl_id_by_control"):
2414
+ self._impl_id_by_control = {}
2415
+ self._impl_id_by_control[control_id] = impl.id
2416
+ return impl.id
1743
2417
  except Exception:
1744
2418
  continue
1745
2419
 
1746
- scan_history.vCritical = severity_counts["Critical"]
1747
- scan_history.vHigh = severity_counts["High"]
1748
- scan_history.vMedium = severity_counts["Moderate"]
1749
- scan_history.vLow = severity_counts["Low"]
1750
- scan_history.vInfo = 0
2420
+ logger.warning(
2421
+ f"⚠️ No control implementation found for control {control_id} among {len(implementations)} implementations"
2422
+ )
2423
+ return None
2424
+ except Exception as e:
2425
+ logger.error(f"Error finding control implementation for {control_id}: {e}")
2426
+ return None
2427
+
2428
+ def _find_assessment_id_for_implementation(self, implementation_id: int) -> Optional[int]:
2429
+ """
2430
+ Find the most recent assessment ID for a control implementation.
2431
+ IMPROVED: Better date handling and caching.
2432
+
2433
+ :param int implementation_id: Control implementation ID
2434
+ :return: Assessment ID if found
2435
+ :rtype: Optional[int]
2436
+ """
2437
+ try:
2438
+ from regscale.models import regscale_models
2439
+ from datetime import datetime
2440
+ from regscale.core.app.utils.app_utils import regscale_string_to_datetime
2441
+
2442
+ # Check cache first
2443
+ if hasattr(self, "_assessment_by_impl_today") and implementation_id in self._assessment_by_impl_today:
2444
+ cached_assessment = self._assessment_by_impl_today[implementation_id]
2445
+ if cached_assessment and hasattr(cached_assessment, "id"):
2446
+ logger.debug(
2447
+ f"Found cached assessment {cached_assessment.id} for implementation {implementation_id}"
2448
+ )
2449
+ return cached_assessment.id
2450
+
2451
+ # Get assessments for this control implementation
2452
+ assessments = regscale_models.Assessment.get_all_by_parent(
2453
+ parent_id=implementation_id, parent_module="controls"
2454
+ )
2455
+
2456
+ if not assessments:
2457
+ logger.warning(f"No assessments found for control implementation {implementation_id}")
2458
+ return None
2459
+
2460
+ # Find the most recent assessment (preferably from today)
2461
+ today = datetime.now().date()
2462
+ today_assessments = []
2463
+ recent_assessments = []
2464
+
2465
+ for assessment in assessments:
2466
+ try:
2467
+ assessment_date = None
2468
+
2469
+ # Try multiple date fields in order of preference
2470
+ date_fields = ["plannedStart", "actualFinish", "plannedFinish", "dateCreated"]
2471
+ for field in date_fields:
2472
+ if hasattr(assessment, field) and getattr(assessment, field):
2473
+ date_value = getattr(assessment, field)
2474
+ if isinstance(date_value, str):
2475
+ assessment_date = regscale_string_to_datetime(date_value).date()
2476
+ elif hasattr(date_value, "date"):
2477
+ assessment_date = date_value.date()
2478
+ else:
2479
+ assessment_date = date_value
2480
+ break
2481
+
2482
+ if assessment_date:
2483
+ if assessment_date == today:
2484
+ today_assessments.append(assessment)
2485
+ else:
2486
+ recent_assessments.append((assessment, assessment_date))
2487
+ else:
2488
+ # Assessment with no parseable date
2489
+ recent_assessments.append((assessment, None))
2490
+ except Exception:
2491
+ recent_assessments.append((assessment, None))
2492
+
2493
+ # Prefer today's assessments
2494
+ if today_assessments:
2495
+ # Sort by ID (highest/newest first) if multiple today
2496
+ today_assessments.sort(key=lambda a: a.id if hasattr(a, "id") else 0, reverse=True)
2497
+ assessment = today_assessments[0]
2498
+ logger.info(
2499
+ f"✓ Found today's assessment {assessment.id} for control implementation {implementation_id}"
2500
+ )
2501
+ # Cache it for future lookups
2502
+ if not hasattr(self, "_assessment_by_impl_today"):
2503
+ self._assessment_by_impl_today = {}
2504
+ self._assessment_by_impl_today[implementation_id] = assessment
2505
+ return assessment.id
2506
+
2507
+ # Fall back to most recent assessment
2508
+ if recent_assessments:
2509
+ # Sort by date (newest first), handling None dates
2510
+ recent_assessments.sort(
2511
+ key=lambda x: (x[1] if x[1] else datetime.min.date(), x[0].id if hasattr(x[0], "id") else 0),
2512
+ reverse=True,
2513
+ )
2514
+ assessment = recent_assessments[0][0]
2515
+ logger.info(f"✓ Found recent assessment {assessment.id} for control implementation {implementation_id}")
2516
+ # Cache it even if not today's
2517
+ if not hasattr(self, "_assessment_by_impl_today"):
2518
+ self._assessment_by_impl_today = {}
2519
+ self._assessment_by_impl_today[implementation_id] = assessment
2520
+ return assessment.id
2521
+
2522
+ logger.warning(f"⚠️ No usable assessments found for control implementation {implementation_id}")
2523
+ return None
2524
+ except Exception as e:
2525
+ logger.error(f"Error finding assessment for control implementation {implementation_id}: {e}")
2526
+ return None
2527
+
2528
+ def _reparent_issue_to_asset(self, issue: regscale_models.Issue) -> None:
2529
+ """
2530
+ Reparent issue to the control implementation instead of the security plan.
2531
+ This ensures issues are properly associated with their control implementations.
2532
+
2533
+ :param regscale_models.Issue issue: Issue to reparent to control implementation
2534
+ :param IntegrationFinding finding: Finding with control information
2535
+ :return: None
2536
+ :rtype: None
2537
+ """
2538
+ # If we have a control implementation ID, parent the issue to it
2539
+ if issue.controlId:
2540
+ issue.parentId = issue.controlId
2541
+ issue.parentModule = "controls"
2542
+ else:
2543
+ # Fall back to security plan if no control implementation found
2544
+ pass
2545
+
2546
+ def _update_scan_history(self, scan_history: regscale_models.ScanHistory) -> None:
2547
+ """
2548
+ No scan history updates for compliance report ingest.
2549
+
2550
+ :param regscale_models.ScanHistory scan_history: Scan history record (unused)
2551
+ """
2552
+ # No scan history for compliance report ingest
2553
+ pass
2554
+
2555
+ def _process_control_assessments(self) -> None:
2556
+ """
2557
+ Process control assessments only for controls that have validated compliance items
2558
+ with existing assets in RegScale. This ensures we don't create assessments for
2559
+ controls that have no assets in our boundary.
2560
+ """
2561
+ logger.info("🎯 Starting control assessment processing for Wiz compliance integration")
2562
+
2563
+ # Ensure existing records cache is loaded
2564
+ self._load_existing_records_cache()
2565
+
2566
+ implementations = self._get_control_implementations()
2567
+ if not implementations:
2568
+ logger.warning("No control implementations found for assessment processing")
2569
+ return
2570
+
2571
+ # Get all potential control IDs from compliance data
2572
+ all_potential_controls = set(self.passing_controls.keys()) | set(self.failing_controls.keys())
2573
+ logger.debug(
2574
+ f"Found {len(all_potential_controls)} potential controls from compliance data: {sorted(all_potential_controls)}"
2575
+ )
2576
+
2577
+ # Validate each control has actual assets in our boundary before processing
2578
+ validated_controls_with_assets = {}
2579
+ validated_passing_controls = {}
2580
+ validated_failing_controls = {}
1751
2581
 
1752
- scan_history.dateLastUpdated = get_current_datetime()
1753
- scan_history.save()
2582
+ for control_id in all_potential_controls:
2583
+ # Get all compliance items for this control
2584
+ control_items = self._get_validated_control_compliance_items(control_id)
2585
+
2586
+ if not control_items:
2587
+ continue
2588
+
2589
+ # Check if we have any assets for the compliance items
2590
+ asset_identifiers = set()
2591
+ assets_found = 0
2592
+
2593
+ for item in control_items:
2594
+ if hasattr(item, "resource_name") and item.resource_name:
2595
+ resource_id = getattr(item, "resource_id", "")
2596
+ # Verify the asset actually exists in RegScale
2597
+ if self._asset_exists_in_regscale(resource_id):
2598
+ asset_identifiers.add(item.resource_name)
2599
+ assets_found += 1
2600
+ else:
2601
+ logger.debug(
2602
+ f"Control {control_id}: Asset {resource_id} ({item.resource_name}) not found in RegScale"
2603
+ )
2604
+ logger.debug(f"Found {assets_found} valid assets for control {control_id}")
2605
+ if not asset_identifiers:
2606
+ continue
2607
+
2608
+ # This control has valid assets, include it in processing
2609
+ validated_controls_with_assets[control_id] = list(asset_identifiers)
2610
+
2611
+ # Preserve the pass/fail status for validated controls
2612
+ if control_id in self.failing_controls:
2613
+ validated_failing_controls[control_id] = self.failing_controls[control_id]
2614
+ elif control_id in self.passing_controls:
2615
+ validated_passing_controls[control_id] = self.passing_controls[control_id]
2616
+
2617
+ if not validated_controls_with_assets:
2618
+ logger.warning("❌ No controls have assets in RegScale boundary - no control assessments will be created")
2619
+ logger.info("📊 SUMMARY: 0 control assessments created (no assets exist in RegScale)")
2620
+ return
2621
+
2622
+ assessments_created = 0
2623
+ processed_impl_today: set[int] = set()
2624
+
2625
+ # Only process validated controls that have assets in our boundary
2626
+ for control_id in validated_controls_with_assets.keys():
2627
+ created = self._process_single_control_assessment(
2628
+ control_id=control_id,
2629
+ implementations=implementations,
2630
+ processed_impl_today=processed_impl_today,
2631
+ )
2632
+ assessments_created += created
2633
+
2634
+ # Calculate stats only for validated controls
2635
+ validated_control_ids = set(validated_controls_with_assets.keys())
2636
+ passing_assessments = len([cid for cid in validated_control_ids if cid not in validated_failing_controls])
2637
+ failing_assessments = len([cid for cid in validated_control_ids if cid in validated_failing_controls])
2638
+
2639
+ if assessments_created > 0:
1754
2640
  logger.info(
1755
- "Updated scan history %s (Critical: %s, High: %s, Medium: %s, Low: %s)",
1756
- getattr(scan_history, "id", 0),
1757
- severity_counts["Critical"],
1758
- severity_counts["High"],
1759
- severity_counts["Moderate"],
1760
- severity_counts["Low"],
2641
+ f" Created {assessments_created} control assessments: {passing_assessments} passing, {failing_assessments} failing"
1761
2642
  )
2643
+ else:
2644
+ logger.warning(
2645
+ f"⚠️ No control assessments were actually created (0 assessments) despite finding {len(validated_controls_with_assets)} controls with assets"
2646
+ )
2647
+
2648
+ logger.info(
2649
+ f"📊 CONTROL ASSESSMENT SUMMARY: {assessments_created} assessments created for {len(validated_controls_with_assets)} validated controls"
2650
+ )
2651
+
2652
+ def _sync_assessment_cache_from_base_class(self) -> None:
2653
+ """
2654
+ Sync assessments from base class cache to our control cache.
2655
+
2656
+ This ensures that assessments created by the base class ComplianceIntegration
2657
+ are available to our IssueFieldSetter for linking issues to assessments.
2658
+ """
2659
+ try:
2660
+ # Copy assessments from base class cache to our cache
2661
+ base_cache = getattr(self, "_assessment_by_impl_today", {})
2662
+ synced_count = 0
2663
+
2664
+ for impl_id, assessment in base_cache.items():
2665
+ self._control_cache.set_assessment(impl_id, assessment)
2666
+ synced_count += 1
2667
+
2668
+ logger.info(f"✅ Synced {synced_count} assessments from base class cache to control cache")
2669
+
2670
+ except Exception as e:
2671
+ logger.warning(f"⚠️ Failed to sync assessment cache: {e}")
2672
+
2673
+ def _get_validated_control_compliance_items(self, control_id: str) -> List[ComplianceItem]:
2674
+ """
2675
+ Get validated compliance items for a specific control.
2676
+ Only returns items that have existing assets in RegScale boundary.
2677
+
2678
+ :param str control_id: Control identifier to filter by
2679
+ :return: List of validated compliance items for the control
2680
+ :rtype: List[ComplianceItem]
2681
+ """
2682
+ validated_items: List[ComplianceItem] = []
2683
+
2684
+ for item in self.all_compliance_items:
2685
+ # Check if this item matches the control
2686
+ matches_control = False
2687
+ if hasattr(item, "control_ids"):
2688
+ item_control_ids = getattr(item, "control_ids", [])
2689
+ if any(cid.lower() == control_id.lower() for cid in item_control_ids):
2690
+ matches_control = True
2691
+ elif hasattr(item, "control_id") and item.control_id.lower() == control_id.lower():
2692
+ matches_control = True
2693
+
2694
+ if not matches_control:
2695
+ continue
2696
+
2697
+ # Additional validation: ensure the asset exists in RegScale
2698
+ resource_id = getattr(item, "resource_id", "")
2699
+ if resource_id and self._asset_exists_in_regscale(resource_id):
2700
+ validated_items.append(item)
2701
+ else:
2702
+ logger.debug(
2703
+ f"Filtered out compliance item for control {control_id} - asset {resource_id} not in RegScale"
2704
+ )
2705
+
2706
+ return validated_items
2707
+
2708
+ def _get_control_compliance_items(self, control_id: str) -> List[ComplianceItem]:
2709
+ """
2710
+ Get all compliance items for a specific control.
2711
+ All items have already been filtered to framework-specific items with existing assets.
2712
+
2713
+ :param str control_id: Control identifier to filter by
2714
+ :return: List of compliance items for the control
2715
+ :rtype: List[ComplianceItem]
2716
+ """
2717
+ items: List[ComplianceItem] = []
2718
+
2719
+ for item in self.all_compliance_items:
2720
+ # Check if this item matches the control
2721
+ matches_control = False
2722
+ if hasattr(item, "control_ids"):
2723
+ item_control_ids = getattr(item, "control_ids", [])
2724
+ if any(cid.lower() == control_id.lower() for cid in item_control_ids):
2725
+ matches_control = True
2726
+ elif hasattr(item, "control_id") and item.control_id.lower() == control_id.lower():
2727
+ matches_control = True
2728
+
2729
+ if matches_control:
2730
+ items.append(item)
2731
+
2732
+ return items
2733
+
2734
+ # flake8: noqa: C901
2735
+ def get_asset_by_identifier(self, identifier: str) -> Optional["regscale_models.Asset"]:
2736
+ """
2737
+ Override asset lookup for Wiz policy compliance integration.
2738
+
2739
+ For policy compliance, the identifier should be the Wiz resource ID.
2740
+ We'll try multiple lookup strategies to find the corresponding RegScale asset.
2741
+
2742
+ :param str identifier: Asset identifier (should be Wiz resource ID)
2743
+ :return: Asset if found, None otherwise
2744
+ :rtype: Optional[regscale_models.Asset]
2745
+ """
2746
+
2747
+ # First try the standard lookup by identifier (uses asset_map_by_identifier)
2748
+ asset = super().get_asset_by_identifier(identifier)
2749
+ if asset:
2750
+ return asset
2751
+
2752
+ # If not found, try to find using our cached RegScale assets by Wiz ID
2753
+ try:
2754
+ if hasattr(self, "_regscale_assets_by_wiz_id") and self._regscale_assets_by_wiz_id:
2755
+ # Direct lookup by Wiz ID (most common case)
2756
+ if identifier in self._regscale_assets_by_wiz_id:
2757
+ regscale_asset = self._regscale_assets_by_wiz_id[identifier]
2758
+ return regscale_asset
2759
+
2760
+ # Fallback: check all assets for name/identifier matches
2761
+ for wiz_id, regscale_asset in self._regscale_assets_by_wiz_id.items():
2762
+ # Check if asset name matches the identifier
2763
+ if regscale_asset.name == identifier:
2764
+ return regscale_asset
2765
+
2766
+ # Also check identifier field
2767
+ if hasattr(regscale_asset, "identifier") and regscale_asset.identifier == identifier:
2768
+ return regscale_asset
2769
+
2770
+ # Check other tracking number
2771
+ if (
2772
+ hasattr(regscale_asset, "otherTrackingNumber")
2773
+ and regscale_asset.otherTrackingNumber == identifier
2774
+ ):
2775
+ logger.debug(
2776
+ f"Found asset via otherTrackingNumber match: {regscale_asset.name} (Wiz ID: {wiz_id})"
2777
+ )
2778
+ return regscale_asset
2779
+
2780
+ except Exception:
2781
+ pass
2782
+
2783
+ # Asset not found
2784
+ return None
2785
+
2786
+ def _ensure_asset_for_finding(self, finding: IntegrationFinding) -> Optional["regscale_models.Asset"]:
2787
+ """
2788
+ Override asset creation for Wiz policy compliance integration.
2789
+
2790
+ We don't create assets in policy compliance integration - they come from
2791
+ separate Wiz inventory import. If an asset isn't found, we skip the finding.
2792
+
2793
+ :param IntegrationFinding finding: Finding that needs an asset
2794
+ :return: None (we don't create assets)
2795
+ :rtype: Optional[regscale_models.Asset]
2796
+ """
2797
+ return None
2798
+
2799
+ def _process_consolidated_issues(self, findings: List[IntegrationFinding]) -> None:
2800
+ """
2801
+ Process pre-consolidated findings to create issues.
2802
+
2803
+ Since fetch_findings() now creates consolidated findings (one per control with all resources),
2804
+ this method simply creates issues directly from each finding.
2805
+
2806
+ :param List[IntegrationFinding] findings: List of pre-consolidated findings to process
2807
+ """
2808
+ if not findings:
2809
+ return
2810
+
2811
+ issues_processed = 0
2812
+
2813
+ for finding in findings:
2814
+ try:
2815
+ control_id = self._normalize_control_id_string(finding.rule_id) or finding.rule_id
2816
+
2817
+ # Create issue title
2818
+ issue_title = self.get_issue_title(finding)
2819
+
2820
+ # Create issue directly from the consolidated finding
2821
+ issue = self.create_or_update_issue_from_finding(title=issue_title, finding=finding)
2822
+ if issue:
2823
+ issues_processed += 1
2824
+
2825
+ else:
2826
+ logger.debug(
2827
+ f"Failed to create issue for control {control_id} - create_or_update_issue_from_finding returned None"
2828
+ )
2829
+
2830
+ except Exception as e:
2831
+ logger.error(f"Error processing consolidated issue for control {control_id}: {e}")
2832
+
2833
+ # Store the count for summary reporting
2834
+ self._issues_processed_count = issues_processed
2835
+
2836
+ def _find_existing_issue_for_control(self) -> Optional["regscale_models.Issue"]:
2837
+ """
2838
+ Find existing issue for a specific control.
2839
+
2840
+ :param str control_id: Control ID to search for
2841
+ :return: Existing issue if found
2842
+ :rtype: Optional[regscale_models.Issue]
2843
+ """
2844
+ # This is a simplified check - in practice you might want to search by external_id or other fields
2845
+ # that uniquely identify control-specific issues
2846
+ return None # For now, always create new issues
2847
+
2848
+ def sync_compliance(self, *args, **kwargs) -> None:
2849
+ """Override sync to use consolidated issue processing and add summary reporting."""
2850
+ # Initialize issue counter
2851
+ self._issues_created_count = 0
2852
+
2853
+ try:
2854
+ # Initialize cache dictionaries if not already initialized
2855
+ if not hasattr(self, "_impl_id_by_control"):
2856
+ self._impl_id_by_control = {}
2857
+ if not hasattr(self, "_assessment_by_impl_today"):
2858
+ self._assessment_by_impl_today = {}
2859
+
2860
+ # Ensure existing records cache is loaded before processing
2861
+ self._load_existing_records_cache()
2862
+
2863
+ # CRITICAL: Pre-populate control implementation cache before any processing
2864
+ logger.info("🎯 Pre-populating control implementation cache for reliable issue linking...")
2865
+ self._populate_control_implementation_cache()
2866
+
2867
+ # Call parent's compliance data processing (assessments, etc.) but skip issue creation
2868
+ original_create_issues = self.create_issues
2869
+ self.create_issues = False # Disable base class issue creation
2870
+ super().sync_compliance() # Call the base ComplianceIntegration.sync_compliance method
2871
+ self.create_issues = original_create_issues # Restore setting
2872
+
2873
+ # CRITICAL: Copy assessments from base class cache to our cache so IssueFieldSetter can find them
2874
+ self._sync_assessment_cache_from_base_class()
2875
+
2876
+ # Now handle issue creation with consolidated logic
2877
+ if self.create_issues:
2878
+ findings = list(self.fetch_findings())
2879
+ if findings:
2880
+ self._process_consolidated_issues(findings)
2881
+
2882
+ # Provide concise summary
2883
+ issues_processed = getattr(self, "_issues_processed_count", 0)
2884
+
2885
+ if issues_processed > 0:
2886
+ # Count actual unique issues in the database for this security plan
2887
+ from regscale.models import regscale_models
2888
+
2889
+ actual_issues = len(
2890
+ regscale_models.Issue.get_all_by_parent(parent_id=self.plan_id, parent_module=self.parent_module)
2891
+ )
2892
+
2893
+ logger.info(
2894
+ f"📊 SUMMARY: Processed {issues_processed} policy violations resulting in {actual_issues} consolidated issues for failed controls for assets in RegScale"
2895
+ )
2896
+ else:
2897
+ logger.info("📊 SUMMARY: No issues processed - no failed controls with existing assets")
2898
+
1762
2899
  except Exception as e:
1763
- logger.error(f"Error updating scan history: {e}")
2900
+ error_and_exit(f"Error during Wiz compliance sync: {e}")
2901
+
2902
+ def _get_regscale_asset_identifier(self, compliance_item: "WizComplianceItem") -> str:
2903
+ """
2904
+ Get the appropriate RegScale asset identifier for a compliance item.
2905
+
2906
+ For Wiz integrations, the asset_identifier_field is "wizId", so we need to return
2907
+ the Wiz resource ID that will match what's stored in the RegScale Asset's wizId field.
2908
+
2909
+ :param WizComplianceItem compliance_item: Compliance item with resource information
2910
+ :return: Wiz resource ID that matches the RegScale Asset's wizId field
2911
+ :rtype: str
2912
+ """
2913
+ resource_id = getattr(compliance_item, "resource_id", "")
2914
+ resource_name = getattr(compliance_item, "resource_name", "")
2915
+
2916
+ # For Wiz policy compliance, the asset identifier should be the Wiz resource ID
2917
+ # because that's what gets stored in RegScale Asset's wizId field (asset_identifier_field = "wizId")
2918
+ if resource_id:
2919
+ return resource_id
2920
+
2921
+ # Fallback (should not normally happen since resource_id is required)
2922
+ return resource_name or "Unknown Resource"
2923
+
2924
+ def _create_consolidated_asset_identifier(self, asset_mappings: Dict[str, Dict[str, str]]) -> str:
2925
+ """
2926
+ Create a consolidated asset identifier with only asset names (one per line).
2927
+
2928
+ Format: "Asset Name 1\nAsset Name 2\nAsset Name 3"
2929
+ This format provides clean, human-readable asset names for POAMs and issues
2930
+ without cluttering them with Wiz resource IDs.
2931
+
2932
+ :param Dict[str, Dict[str, str]] asset_mappings: Map of Wiz resource IDs to asset info
2933
+ :return: Consolidated identifier string with asset names only
2934
+ :rtype: str
2935
+ """
2936
+ if not asset_mappings:
2937
+ return ""
2938
+
2939
+ # Create entries that show only asset names (one per line)
2940
+ identifier_parts = []
2941
+ # Sort by asset name for consistent ordering
2942
+ sorted_mappings = sorted(asset_mappings.items(), key=lambda x: x[1]["name"])
2943
+ for wiz_id, asset_info in sorted_mappings:
2944
+ asset_name = asset_info["name"]
2945
+ wiz_resource_id = asset_info["wiz_id"]
2946
+
2947
+ # Format: Just the asset name (no Wiz resource ID for cleaner POAMs)
2948
+ if asset_name != wiz_resource_id:
2949
+ # Asset was successfully mapped, show only the name
2950
+ identifier_part = asset_name
2951
+ else:
2952
+ # Asset lookup failed, use the Wiz resource ID as fallback
2953
+ identifier_part = wiz_resource_id
2954
+
2955
+ identifier_parts.append(identifier_part)
2956
+
2957
+ # Join with newlines for multi-asset issues
2958
+ consolidated_identifier = "\n".join(identifier_parts)
2959
+ logger.debug(
2960
+ f"Created consolidated asset identifier with {len(identifier_parts)} assets: {consolidated_identifier}"
2961
+ )
2962
+ return consolidated_identifier
1764
2963
 
1765
2964
 
1766
2965
  def resolve_framework_id(framework_input: str) -> str: