regscale-cli 6.24.0.0__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.

regscale/_version.py CHANGED
@@ -33,7 +33,7 @@ def get_version_from_pyproject() -> str:
33
33
  return match.group(1)
34
34
  except Exception:
35
35
  pass
36
- return "6.24.0.0" # fallback version
36
+ return "6.24.0.1" # fallback version
37
37
 
38
38
 
39
39
  __version__ = get_version_from_pyproject()
@@ -719,10 +719,7 @@ def create_and_update_regscale_issues(args: Tuple, thread: int) -> None:
719
719
  parent_module=parent_module,
720
720
  )
721
721
  # create the issue in RegScale
722
- if regscale_issue := Issue.insert_issue(
723
- app=app,
724
- issue=issue,
725
- ):
722
+ if regscale_issue := issue.create():
726
723
  logger.debug(
727
724
  "Created issue #%i-%s in RegScale.",
728
725
  regscale_issue.id,
@@ -812,7 +809,7 @@ def fetch_jira_objects(
812
809
  jira_client: JIRA, jira_project: str, jira_issue_type: str, jql_str: str = None, sync_tasks_only: bool = False
813
810
  ) -> list[jiraIssue]:
814
811
  """
815
- Fetch all issues from Jira for the provided project
812
+ Fetch all issues from Jira for the provided project using the enhanced search API.
816
813
 
817
814
  :param JIRA jira_client: Jira client to use for the request
818
815
  :param str jira_project: Name of the project in Jira
@@ -822,15 +819,77 @@ def fetch_jira_objects(
822
819
  :return: List of Jira issues
823
820
  :rtype: list[jiraIssue]
824
821
  """
825
- start_pointer = 0
826
- page_size = 100
827
- jira_objects = []
828
822
  if sync_tasks_only:
829
823
  validate_issue_type(jira_client, jira_issue_type)
830
824
  output_str = "task"
831
825
  else:
832
826
  output_str = "issue"
833
827
  logger.info("Fetching %s(s) from Jira...", output_str.lower())
828
+ try:
829
+ max_results = 100 # 100 is the max allowed by Jira
830
+ jira_issues = []
831
+ issue_response = jira_client.enhanced_search_issues(
832
+ jql_str=jql_str or f"project = {jira_project}",
833
+ maxResults=max_results,
834
+ )
835
+ jira_issues.extend(issue_response)
836
+ logger.info(
837
+ "%i Jira %s(s) retrieved.",
838
+ len(jira_issues),
839
+ output_str.lower(),
840
+ )
841
+ # Handle pagination if there are more issues to fetch
842
+ while issue_response.nextPageToken:
843
+ issue_response = jira_client.enhanced_search_issues(
844
+ jql_str=jql_str, maxResults=max_results, nextPageToken=issue_response.nextPageToken
845
+ )
846
+ jira_issues.extend(issue_response)
847
+ logger.info(
848
+ "%i Jira %s(s) retrieved.",
849
+ len(jira_issues),
850
+ output_str.lower(),
851
+ )
852
+ # Save artifacts file and log final result if we have issues
853
+ if jira_issues:
854
+ save_jira_issues(jira_issues, jira_project, jira_issue_type)
855
+ logger.info("%i %s(s) retrieved from Jira.", len(jira_issues), output_str.lower())
856
+ return jira_issues
857
+ except Exception as e:
858
+ logger.warning(
859
+ "An error occurred while fetching Jira issues using the enhanced_search_issues method: %s", str(e)
860
+ )
861
+ logger.info("Falling back to the deprecated fetch method...")
862
+
863
+ try:
864
+ return deprecated_fetch_jira_objects(
865
+ jira_client=jira_client,
866
+ jira_project=jira_project,
867
+ jira_issue_type=jira_issue_type,
868
+ jql_str=jql_str,
869
+ output_str=output_str,
870
+ )
871
+ except JIRAError as e:
872
+ error_and_exit(f"Unable to fetch issues from Jira: {e}")
873
+
874
+
875
+ def deprecated_fetch_jira_objects(
876
+ jira_client: JIRA, jira_project: str, jira_issue_type: str, jql_str: str = None, output_str: str = "issue"
877
+ ) -> list[jiraIssue]:
878
+ """
879
+ Fetch all issues from Jira for the provided project using the old API method, used as a fallback method.
880
+
881
+ :param JIRA jira_client: Jira client to use for the request
882
+ :param str jira_project: Name of the project in Jira
883
+ :param str jira_issue_type: Type of issue to fetch from Jira
884
+ :param str jql_str: JQL string to use for the request, default None
885
+ :param str output_str: String to use for logging, either "issue" or "task"
886
+ :return: List of Jira issues
887
+ :rtype: list[jiraIssue]
888
+ """
889
+ start_pointer = 0
890
+ page_size = 100
891
+ jira_objects = []
892
+ logger.info("Fetching %s(s) from Jira...", output_str.lower())
834
893
  # get all issues for the Jira project
835
894
  while True:
836
895
  start = start_pointer * page_size
@@ -851,24 +910,36 @@ def fetch_jira_objects(
851
910
  output_str.lower(),
852
911
  )
853
912
  if jira_objects:
854
- check_file_path("artifacts")
855
- file_name = f"{jira_project.lower()}_existingJira{jira_issue_type}.json"
856
- file_path = Path(f"./artifacts/{file_name}")
857
- save_data_to(
858
- file=file_path,
859
- data=[issue.raw for issue in jira_objects],
860
- output_log=False,
861
- )
862
- logger.info(
863
- "Saved %i Jira %s(s), see %s",
864
- len(jira_objects),
865
- jira_issue_type.lower(),
866
- str(file_path.absolute()),
867
- )
913
+ save_jira_issues(jira_objects, jira_project, jira_issue_type)
868
914
  logger.info("%i %s(s) retrieved from Jira.", len(jira_objects), output_str.lower())
869
915
  return jira_objects
870
916
 
871
917
 
918
+ def save_jira_issues(jira_issues: list[jiraIssue], jira_project: str, jira_issue_type: str) -> None:
919
+ """
920
+ Save Jira issues to a JSON file in the artifacts directory
921
+
922
+ :param list[jiraIssue] jira_issues: List of Jira issues to save
923
+ :param str jira_project: Name of the project in Jira
924
+ :param str jira_issue_type: Type of issue to fetch from Jira
925
+ :rtype: None
926
+ """
927
+ check_file_path("artifacts")
928
+ file_name = f"{jira_project.lower()}_existingJira{jira_issue_type}.json"
929
+ file_path = Path(f"./artifacts/{file_name}")
930
+ save_data_to(
931
+ file=file_path,
932
+ data=[issue.raw for issue in jira_issues],
933
+ output_log=False,
934
+ )
935
+ logger.info(
936
+ "Saved %i Jira %s(s), see %s",
937
+ len(jira_issues),
938
+ jira_issue_type.lower(),
939
+ str(file_path.absolute()),
940
+ )
941
+
942
+
872
943
  def map_jira_to_regscale_issue(jira_issue: jiraIssue, config: dict, parent_id: int, parent_module: str) -> Issue:
873
944
  """
874
945
  Map Jira issues to RegScale issues
@@ -893,6 +964,8 @@ def map_jira_to_regscale_issue(jira_issue: jiraIssue, config: dict, parent_id: i
893
964
  ),
894
965
  status=("Closed" if jira_issue.fields.status.name.lower() == "done" else config["issues"]["jira"]["status"]),
895
966
  jiraId=jira_issue.key,
967
+ identification="Jira Sync",
968
+ sourceReport="Jira",
896
969
  parentId=parent_id,
897
970
  parentModule=parent_module,
898
971
  dateCreated=get_current_datetime(),
@@ -533,6 +533,18 @@ def sync_compliance(
533
533
  default=False,
534
534
  help="Mark created issues as POAMs (default: disabled)",
535
535
  )
536
+ @click.option(
537
+ "--reuse-existing-reports/--no-reuse-existing-reports",
538
+ "-rer/-nrer",
539
+ default=True,
540
+ help="Reuse existing Wiz compliance reports instead of creating new ones (default: enabled)",
541
+ )
542
+ @click.option(
543
+ "--force-fresh-report/--no-force-fresh-report",
544
+ "-ffr/-nffr",
545
+ default=False,
546
+ help="Force creation of a fresh compliance report, ignoring existing reports (default: disabled)",
547
+ )
536
548
  def compliance_report(
537
549
  wiz_project_id,
538
550
  regscale_id,
@@ -543,6 +555,8 @@ def compliance_report(
543
555
  create_issues,
544
556
  update_control_status,
545
557
  create_poams,
558
+ reuse_existing_reports,
559
+ force_fresh_report,
546
560
  ):
547
561
  """
548
562
  Process Wiz compliance reports and create assessments in RegScale.
@@ -557,6 +571,13 @@ def compliance_report(
557
571
  - Create issues for failed compliance assessments (if --create-issues enabled)
558
572
  - Update control implementation status (if --update-control-status enabled)
559
573
  - Support POAM creation for compliance issues
574
+
575
+ REPORT MANAGEMENT:
576
+ By default, the command will look for existing compliance reports in Wiz for the
577
+ specified project and rerun them instead of creating new ones. This prevents the
578
+ accumulation of duplicate reports in Wiz. Use --no-reuse-existing-reports to
579
+ always create new reports, or --force-fresh-report to force a new report even
580
+ when reuse is enabled.
560
581
  """
561
582
  from regscale.integrations.commercial.wizv2.compliance_report import WizComplianceReportProcessor
562
583
 
@@ -579,6 +600,8 @@ def compliance_report(
579
600
  update_control_status=update_control_status,
580
601
  report_file_path=report_file_path,
581
602
  bypass_control_filtering=True, # Bypass filtering for performance with large control sets
603
+ reuse_existing_reports=reuse_existing_reports,
604
+ force_fresh_report=force_fresh_report,
582
605
  )
583
606
 
584
607
  # Process the compliance report using new ComplianceIntegration pattern
@@ -103,27 +103,42 @@ class WizComplianceReportItem(ComplianceItem):
103
103
  return control_ids[0] if control_ids else ""
104
104
 
105
105
  def get_all_control_ids(self) -> list:
106
- """Extract all control IDs from compliance check name."""
106
+ """Extract all control IDs from compliance check name and normalize leading zeros."""
107
107
  if not self.compliance_check_name:
108
108
  return []
109
109
 
110
- # Parse control IDs from compliance check name
111
- # Format: "AC-2(4) Account Management | Automated Audit Actions, AC-6(9) Least Privilege | Log Use of Privileged Functions"
112
- # Use a regex that can find control IDs anywhere in the text
113
110
  control_id_pattern = r"([A-Za-z]{2}-\d+)(?:\s*\(\s*(\d+)\s*\))?"
114
-
115
111
  control_ids = []
112
+
116
113
  for part in self.compliance_check_name.split(", "):
117
114
  matches = re.findall(control_id_pattern, part.strip())
118
115
  for match in matches:
119
116
  base_control, enhancement = match
120
- if enhancement:
121
- control_ids.append(f"{base_control}({enhancement})")
122
- else:
123
- control_ids.append(base_control)
117
+ normalized_control = self._normalize_base_control(base_control)
118
+ formatted_control = self._format_control_id(normalized_control, enhancement)
119
+ control_ids.append(formatted_control)
124
120
 
125
121
  return control_ids
126
122
 
123
+ def _normalize_base_control(self, base_control: str) -> str:
124
+ """Normalize leading zeros in base control number (e.g., AC-01 -> AC-1)."""
125
+ if "-" in base_control:
126
+ prefix, number = base_control.split("-", 1)
127
+ try:
128
+ normalized_number = str(int(number))
129
+ return f"{prefix.upper()}-{normalized_number}"
130
+ except ValueError:
131
+ return base_control.upper()
132
+ else:
133
+ return base_control.upper()
134
+
135
+ def _format_control_id(self, base_control: str, enhancement: str) -> str:
136
+ """Format control ID with optional enhancement."""
137
+ if enhancement:
138
+ return f"{base_control}({enhancement})"
139
+ else:
140
+ return base_control
141
+
127
142
  @property
128
143
  def affected_controls(self) -> str:
129
144
  """Get affected controls as comma-separated string for issues."""
@@ -217,6 +232,7 @@ class WizComplianceReportProcessor(ComplianceIntegration):
217
232
  bypass_control_filtering: bool = False,
218
233
  max_report_age_days: int = 7,
219
234
  force_fresh_report: bool = False,
235
+ reuse_existing_reports: bool = True,
220
236
  **kwargs,
221
237
  ):
222
238
  """
@@ -232,6 +248,7 @@ class WizComplianceReportProcessor(ComplianceIntegration):
232
248
  :param bool bypass_control_filtering: Skip control filtering for performance with large control sets
233
249
  :param int max_report_age_days: Maximum age in days for reusing existing reports (default: 7 days)
234
250
  :param bool force_fresh_report: Force creation of fresh report, ignoring existing reports
251
+ :param bool reuse_existing_reports: Whether to reuse existing Wiz reports instead of creating new ones (default: True)
235
252
  """
236
253
  # Call parent constructor with ComplianceIntegration parameters
237
254
  super().__init__(
@@ -250,6 +267,7 @@ class WizComplianceReportProcessor(ComplianceIntegration):
250
267
  self.bypass_control_filtering = bypass_control_filtering
251
268
  self.max_report_age_days = max_report_age_days
252
269
  self.force_fresh_report = force_fresh_report
270
+ self.reuse_existing_reports = reuse_existing_reports
253
271
  self.title = "Wiz Compliance" # Required by ScannerIntegration
254
272
 
255
273
  # Initialize Wiz authentication
@@ -698,7 +716,7 @@ class WizComplianceReportProcessor(ComplianceIntegration):
698
716
  # Handle force fresh report request
699
717
  if self.force_fresh_report:
700
718
  logger.info("Force fresh report requested, creating new compliance report...")
701
- return self._create_and_download_report()
719
+ return self._create_and_download_report(force_new=True)
702
720
 
703
721
  # Use instance variable max_report_age_days or legacy max_age_hours
704
722
  if max_age_hours is not None:
@@ -756,23 +774,82 @@ class WizComplianceReportProcessor(ComplianceIntegration):
756
774
  logger.info(f"Found recent report (age: {age_hours:.1f}h): {most_recent[0]}")
757
775
  return most_recent[0]
758
776
 
759
- def _create_and_download_report(self) -> Optional[str]:
777
+ def _find_existing_compliance_report(self) -> Optional[str]:
760
778
  """
761
- Create and download a new compliance report.
779
+ Find existing compliance report for the current project.
762
780
 
763
- :return: Path to downloaded report file
781
+ :return: Report ID if found, None otherwise
764
782
  :rtype: Optional[str]
765
783
  """
766
- logger.info(f"Creating compliance report for project: {self.wiz_project_id}")
784
+ try:
785
+ # Filter for compliance reports for this specific project
786
+ filter_by = {"project": [self.wiz_project_id], "type": ["COMPLIANCE_ASSESSMENTS"]}
787
+
788
+ logger.debug(f"Searching for existing compliance reports with filter: {filter_by}")
789
+ reports = self.report_manager.list_reports(filter_by=filter_by)
790
+
791
+ if not reports:
792
+ logger.info("No existing compliance reports found for this project")
793
+ return None
794
+
795
+ # Look for reports named "Compliance Report" (the default name)
796
+ compliance_reports = [report for report in reports if report.get("name", "").strip() == "Compliance Report"]
797
+
798
+ if not compliance_reports:
799
+ logger.info("No compliance reports with standard name found")
800
+ return None
801
+
802
+ # Return the first matching report (most recent will be used)
803
+ selected_report = compliance_reports[0]
804
+ report_id = selected_report.get("id")
805
+ report_name = selected_report.get("name", "Unknown")
806
+
807
+ logger.info(f"Found existing compliance report: '{report_name}' (ID: {report_id})")
808
+ return report_id
767
809
 
768
- # Create report
769
- report_id = self.report_manager.create_compliance_report(self.wiz_project_id)
770
- if not report_id:
771
- logger.error("Failed to create compliance report")
810
+ except Exception as e:
811
+ logger.error(f"Error searching for existing compliance reports: {e}")
772
812
  return None
773
813
 
774
- # Wait for completion and get download URL
775
- download_url = self.report_manager.wait_for_report_completion(report_id)
814
+ def _create_and_download_report(self, force_new: bool = False) -> Optional[str]:
815
+ """
816
+ Find existing compliance report and rerun it, or create a new one if none exists.
817
+
818
+ :param bool force_new: Force creation of new report, skip reuse logic
819
+ :return: Path to downloaded report file
820
+ :rtype: Optional[str]
821
+ """
822
+ if force_new or not self.reuse_existing_reports:
823
+ logger.info("Creating new compliance report (reuse disabled or forced)")
824
+ # Create new report
825
+ report_id = self.report_manager.create_compliance_report(self.wiz_project_id)
826
+ if not report_id:
827
+ logger.error("Failed to create compliance report")
828
+ return None
829
+
830
+ # Wait for completion and get download URL
831
+ download_url = self.report_manager.wait_for_report_completion(report_id)
832
+ else:
833
+ logger.info(f"Looking for existing compliance report for project: {self.wiz_project_id}")
834
+
835
+ # Try to find existing compliance report for this project
836
+ if existing_report_id := self._find_existing_compliance_report():
837
+ logger.info(
838
+ f"Found existing compliance report {existing_report_id}, rerunning instead of creating new one"
839
+ )
840
+ # Rerun existing report
841
+ download_url = self.report_manager.rerun_report(existing_report_id)
842
+ else:
843
+ logger.info("No existing compliance report found, creating new one")
844
+ # Create new report
845
+ report_id = self.report_manager.create_compliance_report(self.wiz_project_id)
846
+ if not report_id:
847
+ logger.error("Failed to create compliance report")
848
+ return None
849
+
850
+ # Wait for completion and get download URL
851
+ download_url = self.report_manager.wait_for_report_completion(report_id)
852
+
776
853
  if not download_url:
777
854
  logger.error("Failed to get download URL for report")
778
855
  return None
@@ -825,6 +902,10 @@ class WizComplianceReportProcessor(ComplianceIntegration):
825
902
  # Prepare batch updates for passing controls
826
903
  implementations_to_update = []
827
904
 
905
+ # Debug: Show what keys are actually in the control_impl_map
906
+ if control_impl_map:
907
+ logger.debug(f"Control implementation map keys: {list(control_impl_map.keys())[:20]}")
908
+
828
909
  for control_id in passing_control_ids:
829
910
  control_id_lower = control_id.lower()
830
911
  logger.debug(f"Looking for control '{control_id_lower}' in implementation map")
@@ -836,8 +917,10 @@ class WizComplianceReportProcessor(ComplianceIntegration):
836
917
  # Get the ControlImplementation object
837
918
  impl = ControlImplementation.get_object(object_id=impl_id)
838
919
  if impl:
839
- # Update status to Implemented
840
- impl.status = ControlImplementationStatus.Implemented.value
920
+ # Update status using compliance settings
921
+ new_status = self._get_implementation_status_from_result("Pass")
922
+ logger.debug(f"Setting control {control_id} status from 'Pass' result to: {new_status}")
923
+ impl.status = new_status
841
924
  impl.dateLastAssessed = get_current_datetime()
842
925
  impl.lastAssessmentResult = "Pass"
843
926
  impl.bStatusImplemented = True
@@ -849,7 +932,7 @@ class WizComplianceReportProcessor(ComplianceIntegration):
849
932
  impl.dateLastUpdated = get_current_datetime()
850
933
 
851
934
  implementations_to_update.append(impl.dict())
852
- logger.info(f"Marking control {control_id} as Implemented")
935
+ logger.info(f"Marking control {control_id} as {new_status}")
853
936
 
854
937
  # Batch update all implementations
855
938
  if implementations_to_update:
@@ -908,6 +991,10 @@ class WizComplianceReportProcessor(ComplianceIntegration):
908
991
  implementations_to_update = []
909
992
  controls_not_found = []
910
993
 
994
+ # Debug: Show what keys are actually in the control_impl_map for comparison
995
+ if control_impl_map:
996
+ logger.debug(f"Control implementation map keys (first 20): {list(control_impl_map.keys())[:20]}")
997
+
911
998
  for control_id in control_ids:
912
999
  control_id_normalized = control_id.lower()
913
1000
  logger.debug(f"Looking for control '{control_id_normalized}' in implementation map")
@@ -946,8 +1033,10 @@ class WizComplianceReportProcessor(ComplianceIntegration):
946
1033
  logger.warning(f"Could not retrieve implementation object for ID {impl_id}")
947
1034
  return None
948
1035
 
949
- # Update status to In Remediation
950
- impl.status = ControlImplementationStatus.InRemediation.value
1036
+ # Update status using compliance settings
1037
+ new_status = self._get_implementation_status_from_result("Fail")
1038
+ logger.debug(f"Setting control {control_id} status from 'Fail' result to: {new_status}")
1039
+ impl.status = new_status
951
1040
  impl.dateLastAssessed = get_current_datetime()
952
1041
  impl.lastAssessmentResult = "Fail"
953
1042
  impl.bStatusImplemented = False
@@ -958,7 +1047,7 @@ class WizComplianceReportProcessor(ComplianceIntegration):
958
1047
  impl.lastUpdatedById = user_id
959
1048
  impl.dateLastUpdated = get_current_datetime()
960
1049
 
961
- logger.info(f"Marking control {control_id} as In Remediation")
1050
+ logger.info(f"Marking control {control_id} as {new_status}")
962
1051
  return impl.dict()
963
1052
 
964
1053
  def _log_update_summary(self, implementations_to_update: list, controls_not_found: list) -> None: