regscale-cli 6.23.0.1__py3-none-any.whl → 6.24.0.1__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 (45) 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/jira.py +95 -22
  5. regscale/integrations/commercial/sarif/sarif_converter.py +1 -1
  6. regscale/integrations/commercial/wizv2/click.py +132 -2
  7. regscale/integrations/commercial/wizv2/compliance_report.py +1574 -0
  8. regscale/integrations/commercial/wizv2/constants.py +72 -2
  9. regscale/integrations/commercial/wizv2/data_fetcher.py +61 -0
  10. regscale/integrations/commercial/wizv2/file_cleanup.py +104 -0
  11. regscale/integrations/commercial/wizv2/issue.py +775 -27
  12. regscale/integrations/commercial/wizv2/policy_compliance.py +599 -181
  13. regscale/integrations/commercial/wizv2/reports.py +243 -0
  14. regscale/integrations/commercial/wizv2/scanner.py +668 -245
  15. regscale/integrations/compliance_integration.py +534 -56
  16. regscale/integrations/due_date_handler.py +210 -0
  17. regscale/integrations/public/cci_importer.py +444 -0
  18. regscale/integrations/scanner_integration.py +718 -153
  19. regscale/models/integration_models/CCI_List.xml +1 -0
  20. regscale/models/integration_models/cisa_kev_data.json +18 -3
  21. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  22. regscale/models/regscale_models/control_implementation.py +13 -3
  23. regscale/models/regscale_models/form_field_value.py +1 -1
  24. regscale/models/regscale_models/milestone.py +1 -0
  25. regscale/models/regscale_models/regscale_model.py +225 -60
  26. regscale/models/regscale_models/security_plan.py +3 -2
  27. regscale/regscale.py +7 -0
  28. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.1.dist-info}/METADATA +17 -17
  29. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.1.dist-info}/RECORD +45 -28
  30. tests/fixtures/test_fixture.py +13 -8
  31. tests/regscale/integrations/public/__init__.py +0 -0
  32. tests/regscale/integrations/public/test_alienvault.py +220 -0
  33. tests/regscale/integrations/public/test_cci.py +458 -0
  34. tests/regscale/integrations/public/test_cisa.py +1021 -0
  35. tests/regscale/integrations/public/test_emass.py +518 -0
  36. tests/regscale/integrations/public/test_fedramp.py +851 -0
  37. tests/regscale/integrations/public/test_fedramp_cis_crm.py +3661 -0
  38. tests/regscale/integrations/public/test_file_uploads.py +506 -0
  39. tests/regscale/integrations/public/test_oscal.py +453 -0
  40. tests/regscale/models/test_form_field_value_integration.py +304 -0
  41. tests/regscale/models/test_module_integration.py +582 -0
  42. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.1.dist-info}/LICENSE +0 -0
  43. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.1.dist-info}/WHEEL +0 -0
  44. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.1.dist-info}/entry_points.txt +0 -0
  45. {regscale_cli-6.23.0.1.dist-info → regscale_cli-6.24.0.1.dist-info}/top_level.txt +0 -0
@@ -25,6 +25,8 @@ from regscale.models.regscale_models import (
25
25
  ControlImplementation,
26
26
  Assessment,
27
27
  ImplementationObjective,
28
+ SecurityPlan,
29
+ ComplianceSettings,
28
30
  )
29
31
 
30
32
  logger = logging.getLogger("regscale")
@@ -160,6 +162,10 @@ class ComplianceIntegration(ScannerIntegration, ABC):
160
162
  # Set scan date
161
163
  self.scan_date = get_current_datetime()
162
164
 
165
+ # Cache for compliance settings
166
+ self._compliance_settings = None
167
+ self._security_plan = None
168
+
163
169
  def is_poam(self, finding: IntegrationFinding) -> bool: # type: ignore[override]
164
170
  """
165
171
  Determines if an issue should be considered a POAM for compliance integrations.
@@ -225,9 +231,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
225
231
  )
226
232
 
227
233
  for asset in existing_assets:
228
- # Cache by external_id, identifier, and other_tracking_number for flexible lookup
229
- if hasattr(asset, "externalId") and asset.externalId:
230
- self._existing_assets_cache[asset.externalId] = asset
234
+ # Cache by identifier and other_tracking_number for flexible lookup
231
235
  if hasattr(asset, "identifier") and asset.identifier:
232
236
  self._existing_assets_cache[asset.identifier] = asset
233
237
  if hasattr(asset, "otherTrackingNumber") and asset.otherTrackingNumber:
@@ -283,13 +287,11 @@ class ComplianceIntegration(ScannerIntegration, ABC):
283
287
  wiz_issues = 0
284
288
  for issue in all_issues:
285
289
  # Cache by external_id and other_identifier for flexible lookup
286
- if hasattr(issue, "externalId") and issue.externalId:
287
- self._existing_issues_cache[issue.externalId] = issue
288
- if "wiz-policy" in issue.externalId.lower():
289
- wiz_issues += 1
290
- logger.debug(f"Cached Wiz issue: {issue.id} -> external_id: {issue.externalId}")
291
290
  if hasattr(issue, "otherIdentifier") and issue.otherIdentifier:
292
291
  self._existing_issues_cache[issue.otherIdentifier] = issue
292
+ if "wiz-policy" in issue.otherIdentifier.lower():
293
+ wiz_issues += 1
294
+ logger.debug(f"Cached Wiz issue: {issue.id} -> other_identifier: {issue.otherIdentifier}")
293
295
 
294
296
  logger.debug(f"Cached {wiz_issues} Wiz policy issues out of {len(all_issues)} total issues")
295
297
 
@@ -411,17 +413,32 @@ class ComplianceIntegration(ScannerIntegration, ABC):
411
413
  """
412
414
  logger.info("Processing compliance data...")
413
415
 
414
- # Reset state to avoid double counting on repeated calls
416
+ self._reset_compliance_state()
417
+ allowed_controls = self._build_allowed_controls_set()
418
+ raw_compliance_data = self.fetch_compliance_data()
419
+
420
+ processing_stats = self._process_raw_compliance_items(raw_compliance_data, allowed_controls)
421
+ self._log_processing_summary(raw_compliance_data, processing_stats)
422
+
423
+ # Perform control-level categorization based on aggregated results
424
+ self._categorize_controls_by_aggregation()
425
+ self._log_final_results()
426
+
427
+ def _reset_compliance_state(self) -> None:
428
+ """Reset state to avoid double counting on repeated calls."""
415
429
  self.all_compliance_items = []
416
430
  self.failed_compliance_items = []
417
431
  self.passing_controls = {}
418
432
  self.failing_controls = {}
419
433
  self.asset_compliance_map.clear()
420
434
 
421
- # Build allowed control IDs from plan/catalog controls to restrict scope
435
+ def _build_allowed_controls_set(self) -> set[str]:
436
+ """Build allowed control IDs from plan/catalog controls to restrict scope."""
422
437
  allowed_controls_normalized: set[str] = set()
423
438
  try:
424
439
  controls = self._get_controls()
440
+ logger.debug(f"Loaded {len(controls)} controls from plan/catalog")
441
+
425
442
  for ctl in controls:
426
443
  cid = (ctl.get("controlId") or "").strip()
427
444
  if not cid:
@@ -429,56 +446,220 @@ class ComplianceIntegration(ScannerIntegration, ABC):
429
446
  base, sub = self._normalize_control_id(cid)
430
447
  normalized = f"{base}({sub})" if sub else base
431
448
  allowed_controls_normalized.add(normalized)
432
- except Exception:
433
- # If controls cannot be loaded, proceed without additional filtering
449
+
450
+ logger.debug(f"Built allowed_controls_normalized set with {len(allowed_controls_normalized)} entries")
451
+ if allowed_controls_normalized:
452
+ sample = sorted(allowed_controls_normalized)[:5]
453
+ logger.debug(f"Sample allowed controls: {sample}")
454
+ except Exception as e:
455
+ logger.warning(f"Could not load controls from plan/catalog: {e}")
434
456
  allowed_controls_normalized = set()
435
457
 
436
- # Fetch raw compliance data
437
- raw_compliance_data = self.fetch_compliance_data()
458
+ return allowed_controls_normalized
459
+
460
+ def _process_raw_compliance_items(self, raw_compliance_data: list, allowed_controls: set) -> dict:
461
+ """Process raw compliance items and return processing statistics.
462
+ :param list raw_compliance_data: Raw compliance data from external system
463
+ :param set allowed_controls: Allowed control IDs
464
+ :return: Processed compliance items
465
+ :rtype: dict
466
+ """
467
+ stats = {"skipped_no_control": 0, "skipped_no_resource": 0, "skipped_not_in_plan": 0, "processed_count": 0}
438
468
 
439
- # Convert to ComplianceItem objects
440
469
  for raw_item in raw_compliance_data:
441
470
  try:
442
471
  compliance_item = self.create_compliance_item(raw_item)
443
- # Skip items that do not resolve to a control or resource
444
- if not getattr(compliance_item, "control_id", "") or not getattr(compliance_item, "resource_id", ""):
472
+ if not self._process_single_compliance_item(compliance_item, allowed_controls, stats):
445
473
  continue
446
-
447
- # If we have an allowed set, restrict to only controls in current plan/catalog
448
- if allowed_controls_normalized:
449
- base, sub = self._normalize_control_id(getattr(compliance_item, "control_id", ""))
450
- norm_item = f"{base}({sub})" if sub else base
451
- if norm_item not in allowed_controls_normalized:
452
- continue
453
- self.all_compliance_items.append(compliance_item)
454
-
455
- # Build asset mapping
456
- self.asset_compliance_map[compliance_item.resource_id].append(compliance_item)
457
-
458
- # Categorize by result
459
- if compliance_item.compliance_result in self.FAIL_STATUSES:
460
- self.failed_compliance_items.append(compliance_item)
461
- # Track failing controls (control can fail if ANY asset fails)
462
- control_key = compliance_item.control_id.lower()
463
- self.failing_controls[control_key] = compliance_item
464
- # Remove from passing if it was there
465
- self.passing_controls.pop(control_key, None)
466
-
467
- elif compliance_item.compliance_result in self.PASS_STATUSES:
468
- control_key = compliance_item.control_id.lower()
469
- # Only mark as passing if not already failing
470
- if control_key not in self.failing_controls:
471
- self.passing_controls[control_key] = compliance_item
472
-
473
474
  except Exception as e:
474
475
  logger.error(f"Error processing compliance item: {e}")
475
476
  continue
476
477
 
478
+ return stats
479
+
480
+ def _process_single_compliance_item(self, compliance_item: Any, allowed_controls: set, stats: dict) -> bool:
481
+ """Process a single compliance item and update statistics. Returns True if processed successfully."""
482
+ control_id = getattr(compliance_item, "control_id", "")
483
+ resource_id = getattr(compliance_item, "resource_id", "")
484
+
485
+ if not control_id:
486
+ stats["skipped_no_control"] += 1
487
+ return False
488
+ if not resource_id:
489
+ stats["skipped_no_resource"] += 1
490
+ return False
491
+
492
+ if not self._should_process_item(compliance_item, control_id, allowed_controls, stats):
493
+ return False
494
+
495
+ self._add_processed_item(compliance_item, stats)
496
+ return True
497
+
498
+ def _should_process_item(self, compliance_item: Any, control_id: str, allowed_controls: set, stats: dict) -> bool:
499
+ """Determine if an item should be processed based on control filtering."""
500
+ if not allowed_controls:
501
+ return True
502
+
503
+ base, sub = self._normalize_control_id(control_id)
504
+ norm_item = f"{base}({sub})" if sub else base
505
+
506
+ if norm_item in allowed_controls:
507
+ return True
508
+
509
+ # Allow PASS controls through even if they don't have existing implementations
510
+ if compliance_item.compliance_result in self.PASS_STATUSES:
511
+ return True
512
+
513
+ stats["skipped_not_in_plan"] += 1
514
+ if stats["skipped_not_in_plan"] <= 3:
515
+ logger.debug(f"Skipping control {norm_item} - not in plan (result: {compliance_item.compliance_result})")
516
+ return False
517
+
518
+ def _add_processed_item(self, compliance_item: Any, stats: dict) -> None:
519
+ """Add a processed item to collections and update statistics."""
520
+ self.all_compliance_items.append(compliance_item)
521
+ stats["processed_count"] += 1
522
+
523
+ # Build asset mapping
524
+ self.asset_compliance_map[compliance_item.resource_id].append(compliance_item)
525
+
526
+ # Categorize by result
527
+ if compliance_item.compliance_result in self.FAIL_STATUSES:
528
+ self.failed_compliance_items.append(compliance_item)
529
+
530
+ def _log_processing_summary(self, raw_compliance_data: list, stats: dict) -> None:
531
+ """Log summary of compliance data processing."""
532
+ logger.debug("Compliance item processing summary:")
533
+ logger.debug(f" - Total raw items: {len(raw_compliance_data)}")
534
+ logger.debug(f" - Skipped (no control_id): {stats['skipped_no_control']}")
535
+ logger.debug(f" - Skipped (no resource_id): {stats['skipped_no_resource']}")
536
+ logger.debug(f" - Skipped (not in plan): {stats['skipped_not_in_plan']}")
537
+ logger.debug(f" - Processed successfully: {stats['processed_count']}")
538
+
539
+ def _log_final_results(self) -> None:
540
+ """Log final processing results."""
477
541
  logger.debug(
478
542
  f"Processed {len(self.all_compliance_items)} compliance items: "
479
543
  f"{len(self.all_compliance_items) - len(self.failed_compliance_items)} passing, "
480
544
  f"{len(self.failed_compliance_items)} failing"
481
545
  )
546
+ logger.debug(
547
+ f"Control categorization: {len(self.passing_controls)} passing controls, "
548
+ f"{len(self.failing_controls)} failing controls"
549
+ )
550
+
551
+ def _categorize_controls_by_aggregation(self) -> None:
552
+ """
553
+ Categorize controls as passing or failing based on aggregated results across all compliance items.
554
+
555
+ This method uses project-scoped aggregation logic instead of the previous "any fail = control fails"
556
+ approach. For project-scoped integrations (like Wiz), this provides more accurate control status.
557
+ """
558
+
559
+ # Group all compliance items by control ID
560
+ control_items = self._group_items_by_control()
561
+
562
+ # Analyze each control's results
563
+ for control_key, items in control_items.items():
564
+ self._categorize_single_control(control_key, items)
565
+
566
+ def _group_items_by_control(self) -> dict:
567
+ """Group compliance items by control ID."""
568
+ from collections import defaultdict
569
+
570
+ control_items = defaultdict(list)
571
+ for item in self.all_compliance_items:
572
+ control_key = item.control_id.lower()
573
+ control_items[control_key].append(item)
574
+
575
+ return control_items
576
+
577
+ def _categorize_single_control(self, control_key: str, items: list) -> None:
578
+ """Categorize a single control based on its compliance items."""
579
+ from collections import Counter
580
+
581
+ results = [item.compliance_result for item in items]
582
+ result_counts = Counter(results)
583
+ total_items = len(results)
584
+
585
+ fail_count, pass_count = self._count_pass_fail_results(result_counts)
586
+
587
+ if fail_count == 0 and pass_count > 0:
588
+ self._mark_control_as_passing(control_key, items, pass_count, fail_count)
589
+ elif fail_count > 0:
590
+ self._handle_control_with_failures(control_key, items, fail_count, pass_count, total_items)
591
+ else:
592
+ logger.debug(f"Control {control_key} has unclear results: {dict(result_counts)}")
593
+
594
+ def _count_pass_fail_results(self, result_counts: dict) -> tuple[int, int]:
595
+ """Count pass and fail results from result counts."""
596
+ fail_statuses_lower = [status.lower() for status in self.FAIL_STATUSES]
597
+ pass_statuses_lower = [status.lower() for status in self.PASS_STATUSES]
598
+
599
+ fail_count = 0
600
+ pass_count = 0
601
+
602
+ for result, count in result_counts.items():
603
+ result_lower = result.lower()
604
+ if result_lower in fail_statuses_lower:
605
+ fail_count += count
606
+ elif result_lower in pass_statuses_lower:
607
+ pass_count += count
608
+
609
+ return fail_count, pass_count
610
+
611
+ def _mark_control_as_passing(self, control_key: str, items: list, pass_count: int, fail_count: int) -> None:
612
+ """Mark a control as passing."""
613
+ self.passing_controls[control_key] = items[0] # Use first item as representative
614
+ logger.debug(f"Control {control_key} marked as PASSING: {pass_count}P/{fail_count}F")
615
+
616
+ def _handle_control_with_failures(
617
+ self, control_key: str, items: list, fail_count: int, pass_count: int, total_items: int
618
+ ) -> None:
619
+ """Handle a control that has some failures."""
620
+ fail_ratio = fail_count / total_items
621
+ failure_threshold = getattr(self, "control_failure_threshold", 0.2)
622
+
623
+ if fail_ratio > failure_threshold:
624
+ self._mark_control_as_failing(control_key, items, pass_count, fail_count, fail_ratio, failure_threshold)
625
+ else:
626
+ self._mark_control_as_passing_with_warnings(
627
+ control_key, items, pass_count, fail_count, fail_ratio, failure_threshold
628
+ )
629
+
630
+ def _mark_control_as_failing(
631
+ self,
632
+ control_key: str,
633
+ items: list,
634
+ pass_count: int,
635
+ fail_count: int,
636
+ fail_ratio: float,
637
+ failure_threshold: float,
638
+ ) -> None:
639
+ """Mark a control as failing due to significant failures."""
640
+ fail_statuses_lower = [status.lower() for status in self.FAIL_STATUSES]
641
+ failing_item = next(item for item in items if item.compliance_result.lower() in fail_statuses_lower)
642
+ self.failing_controls[control_key] = failing_item
643
+ logger.debug(
644
+ f"Control {control_key} marked as FAILING: {pass_count}P/{fail_count}F "
645
+ f"({fail_ratio:.1%} fail rate > {failure_threshold:.1%} threshold)"
646
+ )
647
+
648
+ def _mark_control_as_passing_with_warnings(
649
+ self,
650
+ control_key: str,
651
+ items: list,
652
+ pass_count: int,
653
+ fail_count: int,
654
+ fail_ratio: float,
655
+ failure_threshold: float,
656
+ ) -> None:
657
+ """Mark a control as passing despite low failure rate."""
658
+ self.passing_controls[control_key] = items[0]
659
+ logger.debug(
660
+ f"Control {control_key} marked as PASSING (low fail rate): {pass_count}P/{fail_count}F "
661
+ f"({fail_ratio:.1%} fail rate < {failure_threshold:.1%} threshold)"
662
+ )
482
663
 
483
664
  def create_asset_from_compliance_item(self, compliance_item: ComplianceItem) -> Optional[IntegrationAsset]:
484
665
  """
@@ -660,6 +841,11 @@ class ComplianceIntegration(ScannerIntegration, ABC):
660
841
  assets_processed = self.update_regscale_assets(iter(assets))
661
842
  self._log_asset_results(assets_processed)
662
843
 
844
+ # Refresh the asset map after creating/updating assets to ensure
845
+ # the map contains all assets for issue creation
846
+ logger.debug("Refreshing asset map after asset sync...")
847
+ self.asset_map_by_identifier.update(self.get_asset_map())
848
+
663
849
  def _log_asset_results(self, assets_processed: int) -> None:
664
850
  """
665
851
  Log asset processing results.
@@ -707,8 +893,32 @@ class ComplianceIntegration(ScannerIntegration, ABC):
707
893
  logger.debug("No findings to process into issues")
708
894
  return
709
895
 
710
- issues_created, issues_skipped = self._process_findings_to_issues(findings)
711
- self._log_issue_results(issues_created, issues_skipped)
896
+ # Ensure asset map is populated before processing issues
897
+ # This handles cases where assets were created in previous runs
898
+ if not self.asset_map_by_identifier:
899
+ logger.debug("Loading asset map before issue processing...")
900
+ self.asset_map_by_identifier.update(self.get_asset_map())
901
+
902
+ findings_processed, findings_skipped = self._process_findings_to_issues(findings)
903
+
904
+ # CRITICAL FIX: Flush bulk issue operations to database
905
+ # This ensures all issues created/updated in bulk mode are persisted
906
+ logger.debug(f"Calling bulk_save for {findings_processed} processed findings ({findings_skipped} skipped)...")
907
+ issue_results = regscale_models.Issue.bulk_save()
908
+ logger.debug(
909
+ f"Bulk save completed - created: {issue_results.get('created_count', 0)}, updated: {issue_results.get('updated_count', 0)}"
910
+ )
911
+
912
+ # Update result counts with actual database operations
913
+ if hasattr(self, "_results"):
914
+ if "issues" not in self._results:
915
+ self._results["issues"] = {}
916
+ self._results["issues"].update(issue_results)
917
+
918
+ # Use actual database results for logging
919
+ issues_created = issue_results.get("created_count", 0)
920
+ issues_updated = issue_results.get("updated_count", 0)
921
+ self._log_issue_results_accurate(issues_created, issues_updated, findings_processed, findings_skipped)
712
922
 
713
923
  def _process_findings_to_issues(self, findings: List[IntegrationFinding]) -> tuple[int, int]:
714
924
  """
@@ -720,14 +930,20 @@ class ComplianceIntegration(ScannerIntegration, ABC):
720
930
  issues_created = 0
721
931
  issues_skipped = 0
722
932
 
723
- for finding in findings:
933
+ logger.debug(f"Processing {len(findings)} findings into issues...")
934
+ for i, finding in enumerate(findings):
724
935
  try:
936
+ logger.debug(
937
+ f"Processing finding {i + 1}/{len(findings)}: external_id='{finding.external_id}', asset_identifier='{finding.asset_identifier}"
938
+ )
725
939
  if self._process_single_finding(finding):
726
940
  issues_created += 1
941
+ logger.debug(f" -> Finding {i + 1} processed successfully")
727
942
  else:
728
943
  issues_skipped += 1
944
+ logger.debug(f" -> Finding {i + 1} skipped")
729
945
  except Exception as e:
730
- logger.error(f"Error processing finding: {e}")
946
+ logger.error(f"Error processing finding {i + 1}: {e}")
731
947
  issues_skipped += 1
732
948
 
733
949
  return issues_created, issues_skipped
@@ -739,14 +955,25 @@ class ComplianceIntegration(ScannerIntegration, ABC):
739
955
  :param finding: Finding to process
740
956
  :return: True if issue was created/updated, False if skipped
741
957
  """
958
+ logger.debug(
959
+ f" -> Processing finding: external_id='{finding.external_id}', asset_identifier='{finding.asset_identifier}'"
960
+ )
961
+
742
962
  asset = self._get_or_create_asset_for_finding(finding)
743
963
  if not asset:
964
+ logger.debug(f" -> Asset not found/created for identifier '{finding.asset_identifier}', skipping finding")
744
965
  self._log_asset_not_found_error(finding)
745
966
  return False
746
967
 
968
+ logger.debug(f" -> Found/created asset {asset.id} for identifier '{finding.asset_identifier}'")
747
969
  issue_title = self.get_issue_title(finding)
748
970
  issue = self.create_or_update_issue_from_finding(title=issue_title, finding=finding)
749
- return issue is not None
971
+ success = issue is not None
972
+ if success and issue:
973
+ logger.debug(f" -> Successfully processed finding -> issue {issue.id}")
974
+ else:
975
+ logger.debug(" -> Failed to create/update issue for finding")
976
+ return success
750
977
 
751
978
  def _get_or_create_asset_for_finding(self, finding: IntegrationFinding) -> Optional[regscale_models.Asset]:
752
979
  """
@@ -778,6 +1005,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
778
1005
  def _log_issue_results(self, issues_created: int, issues_skipped: int) -> None:
779
1006
  """
780
1007
  Log issue processing results.
1008
+ DEPRECATED: Use _log_issue_results_accurate for accurate reporting.
781
1009
 
782
1010
  :param int issues_created: Number of issues created/updated
783
1011
  :param int issues_skipped: Number of issues skipped
@@ -791,6 +1019,36 @@ class ComplianceIntegration(ScannerIntegration, ABC):
791
1019
  else:
792
1020
  logger.debug("No issues processed")
793
1021
 
1022
+ def _log_issue_results_accurate(
1023
+ self, issues_created: int, issues_updated: int, findings_processed: int, findings_skipped: int
1024
+ ) -> None:
1025
+ """
1026
+ Log accurate issue processing results based on actual database operations.
1027
+
1028
+ :param int issues_created: Number of new issues created in database
1029
+ :param int issues_updated: Number of existing issues updated in database
1030
+ :param int findings_processed: Number of findings that were processed
1031
+ :param int findings_skipped: Number of findings that were skipped
1032
+ :return: None
1033
+ :rtype: None
1034
+ """
1035
+ total_db_operations = issues_created + issues_updated
1036
+
1037
+ if total_db_operations > 0:
1038
+ logger.info(
1039
+ f"Processed {findings_processed} findings into issues: {issues_created} new issues created, {issues_updated} existing issues updated"
1040
+ )
1041
+ if findings_skipped > 0:
1042
+ logger.info(f"Skipped {findings_skipped} findings (assets not found)")
1043
+ elif findings_skipped > 0:
1044
+ logger.warning(
1045
+ f"Issues processed: 0 created/updated, {findings_skipped} findings skipped (assets not found)"
1046
+ )
1047
+ else:
1048
+ logger.debug(
1049
+ f"Processed {findings_processed} findings but no database changes were needed (all issues up-to-date)"
1050
+ )
1051
+
794
1052
  def _finalize_scan_history(self, scan_history: regscale_models.ScanHistory) -> None:
795
1053
  """
796
1054
  Finalize scan history with error handling.
@@ -1135,6 +1393,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1135
1393
  def _parse_control_id(control_id: str) -> tuple[str, Optional[str]]:
1136
1394
  """
1137
1395
  Parse a control id like 'AC-2(1)', 'AC-2 (1)', 'AC-2-1' into (base, sub).
1396
+ Normalizes leading zeros (e.g., AC-01 becomes AC-1).
1138
1397
 
1139
1398
  Returns (base, None) when no subcontrol.
1140
1399
 
@@ -1148,8 +1407,22 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1148
1407
  if not m:
1149
1408
  return cid.upper(), None
1150
1409
  base = m.group(1).upper()
1410
+ # Normalize leading zeros in base control number (e.g., AC-01 -> AC-1)
1411
+ if "-" in base:
1412
+ prefix, number = base.split("-", 1)
1413
+ try:
1414
+ normalized_number = str(int(number))
1415
+ base = f"{prefix}-{normalized_number}"
1416
+ except ValueError:
1417
+ pass # Keep original if conversion fails
1151
1418
  # Subcontrol may be captured in group 2, 3, or 4 depending on the branch matched
1152
1419
  sub = m.group(2) or m.group(3) or m.group(4)
1420
+ # Normalize leading zeros in subcontrol (e.g., 01 -> 1)
1421
+ if sub:
1422
+ try:
1423
+ sub = str(int(sub))
1424
+ except ValueError:
1425
+ pass # Keep original if conversion fails
1153
1426
  return base, sub
1154
1427
 
1155
1428
  @classmethod
@@ -1181,6 +1454,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1181
1454
  def _normalize_control_id(control_id: str) -> tuple[str, Optional[str]]:
1182
1455
  """
1183
1456
  Normalize control id to a canonical tuple (BASE, SUB) for set membership.
1457
+ Normalizes leading zeros (e.g., AC-01 becomes AC-1).
1184
1458
 
1185
1459
  :param str control_id: Control identifier to normalize
1186
1460
  :return: Tuple of (base_control, subcontrol) in canonical form
@@ -1192,7 +1466,21 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1192
1466
  if not m:
1193
1467
  return cid.upper(), None
1194
1468
  base = m.group(1).upper()
1469
+ # Normalize leading zeros in base control number (e.g., AC-01 -> AC-1)
1470
+ if "-" in base:
1471
+ prefix, number = base.split("-", 1)
1472
+ try:
1473
+ normalized_number = str(int(number))
1474
+ base = f"{prefix}-{normalized_number}"
1475
+ except ValueError:
1476
+ pass # Keep original if conversion fails
1195
1477
  sub = m.group(2) or m.group(3) or m.group(4)
1478
+ # Normalize leading zeros in subcontrol (e.g., 01 -> 1)
1479
+ if sub:
1480
+ try:
1481
+ sub = str(int(sub))
1482
+ except ValueError:
1483
+ pass # Keep original if conversion fails
1196
1484
  return base, sub
1197
1485
 
1198
1486
  def _create_control_assessment(
@@ -1370,9 +1658,198 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1370
1658
 
1371
1659
  return "\n".join(html_parts)
1372
1660
 
1661
+ def _get_security_plan(self) -> Optional[regscale_models.SecurityPlan]:
1662
+ """
1663
+ Get the security plan for this integration.
1664
+
1665
+ :return: SecurityPlan instance or None
1666
+ :rtype: Optional[regscale_models.SecurityPlan]
1667
+ """
1668
+ if self._security_plan is None:
1669
+ try:
1670
+ logger.debug(f"Retrieving security plan with ID: {self.plan_id}")
1671
+ self._security_plan = SecurityPlan.get_object(object_id=self.plan_id)
1672
+ if self._security_plan:
1673
+ logger.debug(f"Retrieved security plan: {self._security_plan.title}")
1674
+ logger.debug(f"complianceSettingsId: {getattr(self._security_plan, 'complianceSettingsId', None)}")
1675
+ else:
1676
+ logger.debug(f"No security plan found with ID: {self.plan_id}")
1677
+ except Exception as e:
1678
+ logger.debug(f"Error getting security plan {self.plan_id}: {e}")
1679
+ self._security_plan = None
1680
+ return self._security_plan
1681
+
1682
+ def _get_compliance_settings(self) -> Optional[regscale_models.ComplianceSettings]:
1683
+ """
1684
+ Get compliance settings for the security plan.
1685
+
1686
+ :return: ComplianceSettings instance or None
1687
+ :rtype: Optional[regscale_models.ComplianceSettings]
1688
+ """
1689
+ if self._compliance_settings is None:
1690
+ try:
1691
+ security_plan = self._get_security_plan()
1692
+ if self._has_valid_compliance_settings_id(security_plan):
1693
+ self._compliance_settings = self._fetch_compliance_settings(security_plan)
1694
+ else:
1695
+ self._log_missing_compliance_settings_reason(security_plan)
1696
+ except Exception as e:
1697
+ logger.debug(f"Error getting compliance settings: {e}")
1698
+ import traceback
1699
+
1700
+ logger.debug(f"Full traceback: {traceback.format_exc()}")
1701
+ self._compliance_settings = None
1702
+ return self._compliance_settings
1703
+
1704
+ def _has_valid_compliance_settings_id(self, security_plan) -> bool:
1705
+ """Check if security plan has valid compliance settings ID."""
1706
+ return security_plan and hasattr(security_plan, "complianceSettingsId") and security_plan.complianceSettingsId
1707
+
1708
+ def _fetch_compliance_settings(self, security_plan) -> Optional[regscale_models.ComplianceSettings]:
1709
+ """Fetch and log compliance settings."""
1710
+ logger.debug(f"Retrieving compliance settings with ID: {security_plan.complianceSettingsId}")
1711
+ compliance_settings = ComplianceSettings.get_object(object_id=security_plan.complianceSettingsId)
1712
+
1713
+ if compliance_settings:
1714
+ logger.debug(f"Using compliance settings: {compliance_settings.title}")
1715
+ logger.debug(
1716
+ f"Compliance settings has field groups: {bool(getattr(compliance_settings, 'complianceSettingsFieldGroups', None))}"
1717
+ )
1718
+ else:
1719
+ logger.debug(f"No compliance settings found for ID: {security_plan.complianceSettingsId}")
1720
+
1721
+ return compliance_settings
1722
+
1723
+ def _log_missing_compliance_settings_reason(self, security_plan) -> None:
1724
+ """Log specific reason why compliance settings are not available."""
1725
+ if not security_plan:
1726
+ logger.debug("Security plan not found")
1727
+ elif not hasattr(security_plan, "complianceSettingsId"):
1728
+ logger.debug("Security plan does not have complianceSettingsId attribute")
1729
+ elif not security_plan.complianceSettingsId:
1730
+ logger.debug("Security plan has no complianceSettingsId set")
1731
+
1732
+ def _get_implementation_status_from_result(self, result: str) -> str:
1733
+ """
1734
+ Get implementation status based on assessment result using compliance settings.
1735
+
1736
+ :param str result: Assessment result ('Pass' or 'Fail')
1737
+ :return: Implementation status string
1738
+ :rtype: str
1739
+ """
1740
+ logger.debug(f"Getting implementation status for result: {result}")
1741
+ compliance_settings = self._get_compliance_settings()
1742
+
1743
+ if compliance_settings:
1744
+ logger.debug(f"Using compliance settings: {compliance_settings.title}")
1745
+ try:
1746
+ status_labels = compliance_settings.get_field_labels("implementationStatus")
1747
+ logger.debug(f"Available status labels: {status_labels}")
1748
+
1749
+ best_match = self._find_best_status_match(result.lower(), status_labels)
1750
+ if best_match:
1751
+ return best_match
1752
+
1753
+ logger.debug(f"No matching compliance setting found for result: {result}")
1754
+ except Exception as e:
1755
+ logger.debug(f"Error using compliance settings for status mapping: {e}")
1756
+ else:
1757
+ logger.debug("No compliance settings available, using default mapping")
1758
+
1759
+ return self._get_default_status(result)
1760
+
1761
+ def _find_best_status_match(self, result_lower: str, status_labels: list) -> Optional[str]:
1762
+ """Find best matching status label for the given result."""
1763
+ best_match = None
1764
+ best_match_score = 0
1765
+
1766
+ for label in status_labels:
1767
+ label_lower = label.lower()
1768
+ logger.debug(f"Checking label '{label}' for result '{result_lower}'")
1769
+
1770
+ if result_lower == "pass":
1771
+ score = self._score_pass_result_label(label_lower)
1772
+ elif result_lower == "fail":
1773
+ score = self._score_fail_result_label(label_lower)
1774
+ else:
1775
+ score = 0
1776
+
1777
+ if score > best_match_score:
1778
+ best_match = label
1779
+ best_match_score = score
1780
+ logger.debug(f"New best match: '{label}' (score: {score})")
1781
+
1782
+ if best_match:
1783
+ logger.debug(
1784
+ f"Selected best match: '{best_match}' (final score: {best_match_score}) for {result_lower} result"
1785
+ )
1786
+
1787
+ return best_match
1788
+
1789
+ def _score_pass_result_label(self, label_lower: str) -> int:
1790
+ """Score a label for Pass results (higher score = better match)."""
1791
+ # Skip negative keywords that shouldn't match Pass results
1792
+ negative_keywords = ["not", "failed", "violation", "remediation", "unsatisfied", "non-compliant"]
1793
+ if any(neg_kw in label_lower for neg_kw in negative_keywords):
1794
+ return 0
1795
+
1796
+ # Exact word matches get highest priority
1797
+ exact_matches = {"implemented": 100, "complete": 95, "compliant": 90, "satisfied": 85}
1798
+ if label_lower in exact_matches:
1799
+ return exact_matches[label_lower]
1800
+
1801
+ # Partial matches get lower priority
1802
+ if "implemented" in label_lower and "fully" not in label_lower and "partially" not in label_lower:
1803
+ return 80
1804
+ elif "complete" in label_lower:
1805
+ return 75
1806
+ elif "compliant" in label_lower:
1807
+ return 70
1808
+ elif "satisfied" in label_lower:
1809
+ return 65
1810
+ elif "fully" in label_lower and "implemented" in label_lower:
1811
+ return 60
1812
+ elif "partially" in label_lower and "implemented" in label_lower:
1813
+ return 55
1814
+ elif "implemented" in label_lower:
1815
+ return 45
1816
+ elif any(kw in label_lower for kw in ["complete", "compliant", "satisfied"]):
1817
+ return 40
1818
+
1819
+ return 0
1820
+
1821
+ def _score_fail_result_label(self, label_lower: str) -> int:
1822
+ """Score a label for Fail results (higher score = better match)."""
1823
+ # Exact word matches get highest priority
1824
+ if label_lower in ["remediation", "in remediation"]:
1825
+ return 100
1826
+ elif label_lower in ["failed", "not implemented"]:
1827
+ return 95
1828
+ elif label_lower in ["non-compliant", "violation"]:
1829
+ return 90
1830
+ elif label_lower == "unsatisfied":
1831
+ return 85
1832
+
1833
+ # Partial matches get lower priority
1834
+ elif "remediation" in label_lower:
1835
+ return 80
1836
+ elif "failed" in label_lower or "not implemented" in label_lower:
1837
+ return 75
1838
+ elif any(kw in label_lower for kw in ["non-compliant", "violation", "unsatisfied"]):
1839
+ return 70
1840
+
1841
+ return 0
1842
+
1843
+ def _get_default_status(self, result: str) -> str:
1844
+ """Get default implementation status when no compliance settings are available."""
1845
+ default_status = "Fully Implemented" if result.lower() == "pass" else "In Remediation"
1846
+ logger.debug(f"Using default status: {default_status}")
1847
+ return default_status
1848
+
1373
1849
  def _update_implementation_status(self, implementation: ControlImplementation, result: str) -> None:
1374
1850
  """
1375
1851
  Update control implementation status based on assessment result.
1852
+ Uses compliance settings from the security plan if available, otherwise falls back to defaults.
1376
1853
 
1377
1854
  :param ControlImplementation implementation: Control implementation to update
1378
1855
  :param str result: Assessment result ('Pass' or 'Fail')
@@ -1380,10 +1857,8 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1380
1857
  :rtype: None
1381
1858
  """
1382
1859
  try:
1383
- if result == "Pass":
1384
- new_status = "Fully Implemented"
1385
- else:
1386
- new_status = "In Remediation"
1860
+ # Get status from compliance settings or fallback to default
1861
+ new_status = self._get_implementation_status_from_result(result)
1387
1862
 
1388
1863
  # Update implementation status
1389
1864
  implementation.status = new_status
@@ -1401,7 +1876,7 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1401
1876
  objective.status = new_status
1402
1877
  objective.save()
1403
1878
 
1404
- logger.debug(f"Updated implementation status to {new_status}")
1879
+ logger.debug(f"Updated implementation status to {new_status} (from compliance settings)")
1405
1880
 
1406
1881
  except Exception as e:
1407
1882
  logger.error(f"Error updating implementation status: {e}")
@@ -1568,17 +2043,18 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1568
2043
 
1569
2044
  # Check for existing issue by external_id first
1570
2045
  external_id = finding.external_id
2046
+ logger.debug(f"Looking for existing issue with external_id: '{external_id}'")
1571
2047
  existing_issue = self._find_existing_issue_cached(external_id)
1572
2048
 
1573
2049
  if existing_issue:
1574
2050
  logger.debug(
1575
- f"Found existing issue {existing_issue.id} for external_id {external_id}, updating instead of creating"
2051
+ f"Found existing issue {existing_issue.id} (other_identifier: '{existing_issue.otherIdentifier}') for lookup external_id '{external_id}', updating instead of creating"
1576
2052
  )
1577
2053
 
1578
2054
  # Update existing issue with new finding data
1579
2055
  existing_issue.title = title
1580
2056
  existing_issue.description = finding.description
1581
- existing_issue.severity = finding.severity
2057
+ existing_issue.severityLevel = finding.severity
1582
2058
  existing_issue.status = finding.status
1583
2059
  # Ensure affectedControls is updated from the finding's control id
1584
2060
  try:
@@ -1598,6 +2074,8 @@ class ComplianceIntegration(ScannerIntegration, ABC):
1598
2074
  except Exception:
1599
2075
  pass
1600
2076
  existing_issue.dateLastUpdated = self.scan_date
2077
+ # Set organization ID based on Issue Owner or SSP Owner hierarchy
2078
+ existing_issue.orgId = self.determine_issue_organization_id(existing_issue.issueOwnerId)
1601
2079
  existing_issue.save()
1602
2080
 
1603
2081
  return existing_issue