regscale-cli 6.25.1.0__py3-none-any.whl → 6.27.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of regscale-cli might be problematic. Click here for more details.

Files changed (146) hide show
  1. regscale/_version.py +1 -1
  2. regscale/airflow/hierarchy.py +2 -2
  3. regscale/core/app/application.py +19 -4
  4. regscale/core/app/internal/evidence.py +419 -2
  5. regscale/core/app/internal/login.py +0 -1
  6. regscale/core/app/utils/catalog_utils/common.py +1 -1
  7. regscale/dev/code_gen.py +24 -20
  8. regscale/integrations/commercial/jira.py +367 -126
  9. regscale/integrations/commercial/qualys/__init__.py +7 -8
  10. regscale/integrations/commercial/qualys/scanner.py +8 -3
  11. regscale/integrations/commercial/sicura/api.py +14 -13
  12. regscale/integrations/commercial/sicura/commands.py +8 -2
  13. regscale/integrations/commercial/sicura/scanner.py +49 -39
  14. regscale/integrations/commercial/stigv2/ckl_parser.py +5 -5
  15. regscale/integrations/commercial/synqly/assets.py +17 -0
  16. regscale/integrations/commercial/synqly/vulnerabilities.py +45 -28
  17. regscale/integrations/commercial/tenablev2/cis_parsers.py +453 -0
  18. regscale/integrations/commercial/tenablev2/cis_scanner.py +447 -0
  19. regscale/integrations/commercial/tenablev2/commands.py +142 -1
  20. regscale/integrations/commercial/tenablev2/scanner.py +0 -1
  21. regscale/integrations/commercial/tenablev2/stig_parsers.py +113 -57
  22. regscale/integrations/commercial/wizv2/WizDataMixin.py +1 -1
  23. regscale/integrations/commercial/wizv2/click.py +64 -79
  24. regscale/integrations/commercial/wizv2/compliance/__init__.py +15 -0
  25. regscale/integrations/commercial/wizv2/{policy_compliance_helpers.py → compliance/helpers.py} +78 -60
  26. regscale/integrations/commercial/wizv2/compliance_report.py +161 -165
  27. regscale/integrations/commercial/wizv2/core/__init__.py +133 -0
  28. regscale/integrations/commercial/wizv2/{async_client.py → core/client.py} +3 -3
  29. regscale/integrations/commercial/wizv2/{constants.py → core/constants.py} +1 -17
  30. regscale/integrations/commercial/wizv2/core/file_operations.py +237 -0
  31. regscale/integrations/commercial/wizv2/fetchers/__init__.py +11 -0
  32. regscale/integrations/commercial/wizv2/{data_fetcher.py → fetchers/policy_assessment.py} +5 -9
  33. regscale/integrations/commercial/wizv2/issue.py +1 -1
  34. regscale/integrations/commercial/wizv2/models/__init__.py +0 -0
  35. regscale/integrations/commercial/wizv2/parsers/__init__.py +34 -0
  36. regscale/integrations/commercial/wizv2/{parsers.py → parsers/main.py} +1 -1
  37. regscale/integrations/commercial/wizv2/processors/__init__.py +11 -0
  38. regscale/integrations/commercial/wizv2/{finding_processor.py → processors/finding.py} +1 -1
  39. regscale/integrations/commercial/wizv2/reports.py +1 -1
  40. regscale/integrations/commercial/wizv2/sbom.py +1 -1
  41. regscale/integrations/commercial/wizv2/scanner.py +39 -99
  42. regscale/integrations/commercial/wizv2/utils/__init__.py +48 -0
  43. regscale/integrations/commercial/wizv2/{utils.py → utils/main.py} +116 -61
  44. regscale/integrations/commercial/wizv2/variables.py +89 -3
  45. regscale/integrations/compliance_integration.py +60 -41
  46. regscale/integrations/control_matcher.py +377 -0
  47. regscale/integrations/due_date_handler.py +14 -8
  48. regscale/integrations/milestone_manager.py +291 -0
  49. regscale/integrations/public/__init__.py +1 -0
  50. regscale/integrations/public/cci_importer.py +37 -38
  51. regscale/integrations/public/fedramp/click.py +60 -2
  52. regscale/integrations/public/fedramp/docx_parser.py +10 -1
  53. regscale/integrations/public/fedramp/fedramp_cis_crm.py +393 -340
  54. regscale/integrations/public/fedramp/fedramp_five.py +1 -1
  55. regscale/integrations/public/fedramp/poam_export_v5.py +888 -0
  56. regscale/integrations/scanner_integration.py +277 -153
  57. regscale/models/integration_models/cisa_kev_data.json +282 -9
  58. regscale/models/integration_models/nexpose.py +36 -10
  59. regscale/models/integration_models/qualys.py +3 -4
  60. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  61. regscale/models/integration_models/synqly_models/connectors/vulnerabilities.py +24 -7
  62. regscale/models/integration_models/synqly_models/synqly_model.py +8 -1
  63. regscale/models/locking.py +12 -8
  64. regscale/models/platform.py +1 -2
  65. regscale/models/regscale_models/control_implementation.py +47 -22
  66. regscale/models/regscale_models/issue.py +256 -95
  67. regscale/models/regscale_models/milestone.py +1 -1
  68. regscale/models/regscale_models/regscale_model.py +6 -1
  69. regscale/templates/__init__.py +0 -0
  70. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.27.0.0.dist-info}/METADATA +1 -17
  71. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.27.0.0.dist-info}/RECORD +145 -65
  72. tests/regscale/integrations/commercial/__init__.py +0 -0
  73. tests/regscale/integrations/commercial/conftest.py +28 -0
  74. tests/regscale/integrations/commercial/microsoft_defender/__init__.py +1 -0
  75. tests/regscale/integrations/commercial/microsoft_defender/test_defender.py +1517 -0
  76. tests/regscale/integrations/commercial/microsoft_defender/test_defender_api.py +1748 -0
  77. tests/regscale/integrations/commercial/microsoft_defender/test_defender_constants.py +327 -0
  78. tests/regscale/integrations/commercial/microsoft_defender/test_defender_scanner.py +487 -0
  79. tests/regscale/integrations/commercial/test_aws.py +3731 -0
  80. tests/regscale/integrations/commercial/test_burp.py +48 -0
  81. tests/regscale/integrations/commercial/test_crowdstrike.py +49 -0
  82. tests/regscale/integrations/commercial/test_dependabot.py +341 -0
  83. tests/regscale/integrations/commercial/test_gcp.py +1543 -0
  84. tests/regscale/integrations/commercial/test_gitlab.py +549 -0
  85. tests/regscale/integrations/commercial/test_ip_mac_address_length.py +84 -0
  86. tests/regscale/integrations/commercial/test_jira.py +2204 -0
  87. tests/regscale/integrations/commercial/test_npm_audit.py +42 -0
  88. tests/regscale/integrations/commercial/test_okta.py +1228 -0
  89. tests/regscale/integrations/commercial/test_sarif_converter.py +251 -0
  90. tests/regscale/integrations/commercial/test_sicura.py +350 -0
  91. tests/regscale/integrations/commercial/test_snow.py +423 -0
  92. tests/regscale/integrations/commercial/test_sonarcloud.py +394 -0
  93. tests/regscale/integrations/commercial/test_sqlserver.py +186 -0
  94. tests/regscale/integrations/commercial/test_stig.py +33 -0
  95. tests/regscale/integrations/commercial/test_stig_mapper.py +153 -0
  96. tests/regscale/integrations/commercial/test_stigv2.py +406 -0
  97. tests/regscale/integrations/commercial/test_wiz.py +1365 -0
  98. tests/regscale/integrations/commercial/test_wiz_inventory.py +256 -0
  99. tests/regscale/integrations/commercial/wizv2/__init__.py +339 -0
  100. tests/regscale/integrations/commercial/wizv2/compliance/__init__.py +1 -0
  101. tests/regscale/integrations/commercial/wizv2/compliance/test_helpers.py +903 -0
  102. tests/regscale/integrations/commercial/wizv2/core/__init__.py +1 -0
  103. tests/regscale/integrations/commercial/wizv2/core/test_auth.py +701 -0
  104. tests/regscale/integrations/commercial/wizv2/core/test_client.py +1037 -0
  105. tests/regscale/integrations/commercial/wizv2/core/test_file_operations.py +989 -0
  106. tests/regscale/integrations/commercial/wizv2/fetchers/__init__.py +1 -0
  107. tests/regscale/integrations/commercial/wizv2/fetchers/test_policy_assessment.py +805 -0
  108. tests/regscale/integrations/commercial/wizv2/parsers/__init__.py +1 -0
  109. tests/regscale/integrations/commercial/wizv2/parsers/test_main.py +1153 -0
  110. tests/regscale/integrations/commercial/wizv2/processors/__init__.py +1 -0
  111. tests/regscale/integrations/commercial/wizv2/processors/test_finding.py +671 -0
  112. tests/regscale/integrations/commercial/wizv2/test_WizDataMixin.py +537 -0
  113. tests/regscale/integrations/commercial/wizv2/test_click_comprehensive.py +851 -0
  114. tests/regscale/integrations/commercial/wizv2/test_compliance_report_comprehensive.py +910 -0
  115. tests/regscale/integrations/commercial/wizv2/test_compliance_report_normalization.py +138 -0
  116. tests/regscale/integrations/commercial/wizv2/test_file_cleanup.py +283 -0
  117. tests/regscale/integrations/commercial/wizv2/test_file_operations.py +260 -0
  118. tests/regscale/integrations/commercial/wizv2/test_issue.py +343 -0
  119. tests/regscale/integrations/commercial/wizv2/test_issue_comprehensive.py +1203 -0
  120. tests/regscale/integrations/commercial/wizv2/test_reports.py +497 -0
  121. tests/regscale/integrations/commercial/wizv2/test_sbom.py +643 -0
  122. tests/regscale/integrations/commercial/wizv2/test_scanner_comprehensive.py +805 -0
  123. tests/regscale/integrations/commercial/wizv2/test_wiz_click_client_id.py +165 -0
  124. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_report.py +1394 -0
  125. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_unit.py +341 -0
  126. tests/regscale/integrations/commercial/wizv2/test_wiz_control_normalization.py +138 -0
  127. tests/regscale/integrations/commercial/wizv2/test_wiz_findings_comprehensive.py +364 -0
  128. tests/regscale/integrations/commercial/wizv2/test_wiz_inventory_comprehensive.py +644 -0
  129. tests/regscale/integrations/commercial/wizv2/test_wiz_status_mapping.py +149 -0
  130. tests/regscale/integrations/commercial/wizv2/test_wizv2.py +1132 -0
  131. tests/regscale/integrations/commercial/wizv2/test_wizv2_utils.py +519 -0
  132. tests/regscale/integrations/commercial/wizv2/utils/__init__.py +1 -0
  133. tests/regscale/integrations/commercial/wizv2/utils/test_main.py +1523 -0
  134. tests/regscale/integrations/public/fedramp/__init__.py +1 -0
  135. tests/regscale/integrations/public/fedramp/test_poam_export_v5.py +1293 -0
  136. tests/regscale/integrations/public/test_fedramp.py +301 -0
  137. tests/regscale/integrations/test_control_matcher.py +1397 -0
  138. tests/regscale/integrations/test_control_matching.py +155 -0
  139. tests/regscale/integrations/test_milestone_manager.py +408 -0
  140. tests/regscale/models/test_issue.py +378 -1
  141. regscale/integrations/commercial/wizv2/policy_compliance.py +0 -3543
  142. /regscale/integrations/commercial/wizv2/{wiz_auth.py → core/auth.py} +0 -0
  143. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.27.0.0.dist-info}/LICENSE +0 -0
  144. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.27.0.0.dist-info}/WHEEL +0 -0
  145. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.27.0.0.dist-info}/entry_points.txt +0 -0
  146. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.27.0.0.dist-info}/top_level.txt +0 -0
@@ -12,7 +12,9 @@ from collections import Counter
12
12
  from concurrent.futures import as_completed
13
13
  from concurrent.futures.thread import ThreadPoolExecutor
14
14
  from datetime import datetime
15
+ from functools import lru_cache
15
16
  from pathlib import Path
17
+ from tempfile import gettempdir
16
18
  from threading import Thread
17
19
  from types import ModuleType
18
20
  from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Tuple, TypeVar
@@ -23,6 +25,7 @@ from regscale.core.app.api import Api
23
25
  from regscale.core.app.utils.api_handler import APIInsertionError, APIUpdateError
24
26
  from regscale.core.app.utils.app_utils import compute_hash, create_progress_object, error_and_exit, get_current_datetime
25
27
  from regscale.core.utils.graphql import GraphQLQuery
28
+ from regscale.integrations.control_matcher import ControlMatcher
26
29
  from regscale.integrations.public.fedramp.ssp_logger import SSPLogger
27
30
  from regscale.models import ControlObjective, ImplementationObjective, ImportValidater, Parameter, Profile
28
31
  from regscale.models.regscale_models import (
@@ -41,9 +44,6 @@ from regscale.utils.version import RegscaleVersion
41
44
  if TYPE_CHECKING:
42
45
  import pandas as pd
43
46
 
44
- from functools import lru_cache
45
- from tempfile import gettempdir
46
-
47
47
  T = TypeVar("T")
48
48
 
49
49
  logger = SSPLogger()
@@ -116,131 +116,91 @@ def get_pandas() -> ModuleType:
116
116
  return pd
117
117
 
118
118
 
119
- def smart_find_by_source(
120
- source: str, control_objectives: List[ControlObjective]
121
- ) -> Tuple[Optional[str], List[str], str]:
122
- """
123
- Smart algorithm to find mapping by source, checking ControlObjective table only.
124
-
125
- :param str source: The source control ID (e.g., "AC-1(a)", "AC-01 (a)")
126
- :param List[ControlObjective] control_objectives: List of ControlObjective objects to search
127
- :return: Tuple of (primary_oscal_id, sub_parts, status_message)
128
- :rtype: Tuple[Optional[str], List[str], str]
119
+ def _build_potential_oscal_ids(variation: str) -> List[str]:
129
120
  """
130
- # Step 1: Convert control name to OSCAL identifier
131
- expected_oscal = _convert_to_oscal_identifier(source)
132
-
133
- if not expected_oscal:
134
- return None, [], f"Unable to convert control {source} to OSCAL format"
135
-
136
- # Step 2: Search otherId field in ControlObjective table for exact match
137
- if _find_exact_objective_by_other_id(expected_oscal, control_objectives):
138
- return expected_oscal, [], f"Found exact match: {expected_oscal}"
139
-
140
- # Step 3: Search for subparts (pattern: expected_oscal + ".*")
141
- if sub_parts := _find_subpart_objectives_by_other_id(expected_oscal, control_objectives):
142
- return None, sub_parts, f"Control exists with {len(sub_parts)} sub-parts. Update import file."
121
+ Build potential OSCAL ID formats from a control ID variation.
143
122
 
144
- # Step 4: No match found
145
- return None, [], f"No database match found for {source} (expected: {expected_oscal})"
146
-
147
-
148
- def _convert_to_oscal_identifier(source: str) -> Optional[str]:
149
- """
150
- Convert control name to OSCAL identifier using algorithmic patterns.
151
-
152
- :param str source: The source control ID (e.g., "AC-1(a)", "AC-01 (a)", "AC-6(1)")
153
- :return: Generated OSCAL identifier or None
154
- :rtype: Optional[str]
123
+ :param str variation: Control ID variation (e.g., "AC-1", "AC-01")
124
+ :return: List of potential OSCAL IDs
125
+ :rtype: List[str]
155
126
  """
156
- # Remove extra spaces and normalize
157
- source = source.strip()
158
-
159
- # Pattern 1: Control enhancement - AC-6(1), AC-02 (01), AC-6 ( 1 )
160
- if match := re.match(r"^([A-Z]{2})-(\d{1,2})\s*\(\s*(\d{1,2})\s*\)$", source, re.IGNORECASE):
161
- family, number, enhancement = match.groups()
162
- return f"{family.lower()}-{int(number)}.{int(enhancement)}_smt"
163
-
164
- # Pattern 2: Control part - AC-1(a), AC-01 (a), AC-1 ( a )
165
- if match := re.match(r"^([A-Z]{2})-(\d{1,2})\s*\(\s*([a-z])\s*\)$", source, re.IGNORECASE):
166
- family, number, part = match.groups()
167
- return f"{family.lower()}-{int(number)}_smt.{part.lower()}"
168
-
169
- # Pattern 3: Control enhancement part - AC-6(1)(a), AC-02 (07) (a), AC-6 ( 1 ) ( a )
170
- if match := re.match(r"^([A-Z]{2})-(\d{1,2})\s*\(\s*(\d{1,2})\s*\)\s*\(\s*([a-z])\s*\)$", source, re.IGNORECASE):
171
- family, number, enhancement, part = match.groups()
172
- return f"{family.lower()}-{int(number)}.{int(enhancement)}_smt.{part.lower()}"
173
-
174
- # Pattern 4: Base control - AC-1, AC-01
175
- if match := re.match(r"^([A-Z]{2})-(\d{1,2})$", source, re.IGNORECASE):
176
- family, number = match.groups()
177
- return f"{family.lower()}-{int(number)}_smt"
178
-
179
- return None
127
+ variation_lower = variation.lower()
128
+ return [
129
+ f"{variation_lower}_smt",
130
+ f"{variation_lower}_smt.a",
131
+ f"{variation_lower}_smt.b",
132
+ f"{variation_lower}_smt.c",
133
+ ]
180
134
 
181
135
 
182
- def _find_exact_objective_by_other_id(expected_oscal: str, control_objectives: List[ControlObjective]) -> bool:
136
+ def _matches_oscal_id(obj_id: str, variation: str) -> bool:
183
137
  """
184
- Check if exact OSCAL identifier exists in ControlObjective otherId field.
138
+ Check if an objective's otherId matches any OSCAL ID format for the given variation.
185
139
 
186
- :param str expected_oscal: The expected OSCAL identifier
187
- :param List[ControlObjective] control_objectives: List of ControlObjective objects
188
- :return: True if exact match found
140
+ :param str obj_id: The objective's otherId
141
+ :param str variation: Control ID variation
142
+ :return: True if matches, False otherwise
189
143
  :rtype: bool
190
144
  """
191
- for obj in control_objectives:
192
- if hasattr(obj, "otherId") and obj.otherId == expected_oscal:
193
- return True
194
- return False
145
+ potential_ids = _build_potential_oscal_ids(variation)
146
+ return obj_id in potential_ids or obj_id.startswith(f"{variation.lower()}_smt")
195
147
 
196
148
 
197
- def _convert_oscal_to_rev4_control_label(oscal_control_id: str) -> str:
149
+ def _find_matching_objectives(control_objectives: List[ControlObjective], variations: set) -> List[ControlObjective]:
198
150
  """
199
- Convert OSCAL control ID to Rev4 control label format.
200
-
201
- Examples:
202
- - "ac-1" -> "ac-01"
203
- - "ac-10" -> "ac-10"
204
- - "ac-2.7" -> "ac-02"
151
+ Find objectives that match any of the control ID variations.
205
152
 
206
- :param str oscal_control_id: OSCAL control ID (e.g., "ac-1", "ac-2.7")
207
- :return: Rev4 control label (e.g., "ac-01", "ac-02")
208
- :rtype: str
153
+ :param List[ControlObjective] control_objectives: List of objectives to search
154
+ :param set variations: Set of control ID variations
155
+ :return: List of matched objectives
156
+ :rtype: List[ControlObjective]
209
157
  """
210
- # Handle control enhancements by taking just the base control
211
- base_control = oscal_control_id.split(".")[0] # "ac-2.7" -> "ac-2"
212
-
213
- # Split into family and number
214
- parts = base_control.split("-")
215
- if len(parts) != 2:
216
- return oscal_control_id # Return as-is if not in expected format
158
+ matched_objectives = []
217
159
 
218
- family, number = parts
219
-
220
- # Convert single digit to zero-padded format: "1" -> "01"
221
- if len(number) == 1:
222
- number = f"0{number}"
160
+ for obj in control_objectives:
161
+ if not hasattr(obj, "otherId") or not obj.otherId:
162
+ continue
163
+
164
+ obj_id = obj.otherId
165
+ for variation in variations:
166
+ if _matches_oscal_id(obj_id, variation):
167
+ if obj not in matched_objectives:
168
+ matched_objectives.append(obj)
169
+ break
223
170
 
224
- return f"{family}-{number}"
171
+ return matched_objectives
225
172
 
226
173
 
227
- def _find_subpart_objectives_by_other_id(base_oscal: str, control_objectives: List[ControlObjective]) -> List[str]:
174
+ def find_objectives_using_control_matcher(
175
+ source: str, control_objectives: List[ControlObjective], control_matcher: ControlMatcher
176
+ ) -> Tuple[List[ControlObjective], str]:
228
177
  """
229
- Find sub-part objectives that start with the base OSCAL identifier pattern.
178
+ Find control objectives using ControlMatcher for consistent control ID parsing and matching.
230
179
 
231
- :param str base_oscal: The base OSCAL identifier (e.g., "ac-2.7_smt")
232
- :param List[ControlObjective] control_objectives: List of ControlObjective objects
233
- :return: List of sub-part OSCAL identifiers
234
- :rtype: List[str]
180
+ :param str source: The source control ID (e.g., "AC-1(a)", "AC-01 (a)")
181
+ :param List[ControlObjective] control_objectives: List of ControlObjective objects to search
182
+ :param ControlMatcher control_matcher: Instance of ControlMatcher for parsing and variations
183
+ :return: Tuple of (matched objectives list, status_message)
184
+ :rtype: Tuple[List[ControlObjective], str]
235
185
  """
236
- sub_parts = []
237
- base_pattern = base_oscal + "."
186
+ # Parse the control ID using ControlMatcher
187
+ parsed_id = control_matcher.parse_control_id(source)
188
+ if not parsed_id:
189
+ return [], f"Unable to parse control {source}"
238
190
 
239
- for obj in control_objectives:
240
- if hasattr(obj, "otherId") and obj.otherId.startswith(base_pattern):
241
- sub_parts.append(obj.otherId)
191
+ # Get all variations of this control ID
192
+ # pylint: disable=protected-access # Using internal method for control ID variation matching
193
+ variations = control_matcher._get_control_id_variations(parsed_id)
194
+ if not variations:
195
+ return [], f"Unable to generate variations for {source}"
196
+
197
+ # Find matching objectives
198
+ matched_objectives = _find_matching_objectives(control_objectives, variations)
242
199
 
243
- return sorted(sub_parts)
200
+ if matched_objectives:
201
+ return matched_objectives, f"Found {len(matched_objectives)} objective(s) for {source}"
202
+
203
+ return [], f"No database match found for {source} (parsed: {parsed_id})"
244
204
 
245
205
 
246
206
  def transform_control(control: str) -> str:
@@ -314,7 +274,7 @@ def gen_key(control_id: str):
314
274
  # 1. The last (number) if it exists
315
275
  # 2. The main control number if no enhancement exists
316
276
  # And excludes any trailing (letter) - handles extra spaces like AC-6 ( 1 ) ( a )
317
- pattern = r"^((?:\w+-\d+(?:\s*\(\s*\d+\s*\))?))(?:\s*\(\s*[a-zA-Z]\s*\))?$"
277
+ pattern = r"^(\w+-\d+(?:\s*\(\s*\d+\s*\))?)(?:\s*\(\s*[a-zA-Z]\s*\))?$"
318
278
 
319
279
  match = re.match(pattern, control_id)
320
280
  if match:
@@ -392,7 +352,7 @@ def map_origination(control_id: str, cis_data: dict) -> dict:
392
352
  }
393
353
 
394
354
  # Initialize result with all flags set to False
395
- result = {key: False for key in origination_mapping.values()}
355
+ result = dict.fromkeys(origination_mapping.values(), False)
396
356
  result["record_text"] = ""
397
357
 
398
358
  # Find matching CIS records
@@ -469,6 +429,103 @@ def get_multi_status(record: dict) -> str:
469
429
  return status_map.get(implementation_status, NOT_IMPLEMENTED)
470
430
 
471
431
 
432
+ def _calculate_responsibility(control_originations: List[str], imp: ControlImplementation) -> str:
433
+ """
434
+ Calculate responsibility from control originations.
435
+
436
+ :param List[str] control_originations: List of control origination values
437
+ :param ControlImplementation imp: Control implementation
438
+ :return: Calculated responsibility value
439
+ :rtype: str
440
+ """
441
+ try:
442
+ if RegscaleVersion.meets_minimum_version("6.20.17.0"):
443
+ return ",".join(control_originations)
444
+ return next(iter(control_originations))
445
+ except StopIteration:
446
+ if imp.responsibility:
447
+ return imp.responsibility.split(",")[0]
448
+ return SERVICE_PROVIDER_CORPORATE
449
+
450
+
451
+ def _create_new_implementation_objective(
452
+ leverage_auth_id: int,
453
+ imp: ControlImplementation,
454
+ objective: ControlObjective,
455
+ cis_record: dict,
456
+ responsibility: str,
457
+ cloud_responsibility: str,
458
+ customer_responsibility: str,
459
+ can_be_inherited_from_csp: str,
460
+ ) -> ImplementationObjective:
461
+ """
462
+ Create a new implementation objective.
463
+
464
+ :param int leverage_auth_id: Leveraged authorization ID
465
+ :param ControlImplementation imp: Control implementation
466
+ :param ControlObjective objective: Control objective
467
+ :param dict cis_record: CIS record data
468
+ :param str responsibility: Responsibility value
469
+ :param str cloud_responsibility: Cloud responsibility value
470
+ :param str customer_responsibility: Customer responsibility value
471
+ :param str can_be_inherited_from_csp: Can be inherited flag
472
+ :return: New implementation objective
473
+ :rtype: ImplementationObjective
474
+ """
475
+ imp_obj = ImplementationObjective(
476
+ id=0,
477
+ uuid="",
478
+ inherited=can_be_inherited_from_csp in ["Yes", "Partial"],
479
+ implementationId=imp.id,
480
+ status=get_multi_status(cis_record),
481
+ objectiveId=objective.id,
482
+ notes=objective.name,
483
+ securityControlId=objective.securityControlId,
484
+ securityPlanId=REGSCALE_SSP_ID,
485
+ responsibility=responsibility,
486
+ cloudResponsibility=cloud_responsibility,
487
+ customerResponsibility=customer_responsibility,
488
+ authorizationId=leverage_auth_id,
489
+ parentObjectiveId=objective.parentObjectiveId,
490
+ )
491
+ logger.debug(
492
+ "Creating new Implementation Objective for Control %s with status: %s responsibility: %s",
493
+ imp_obj.securityControlId,
494
+ imp_obj.status,
495
+ imp_obj.responsibility,
496
+ )
497
+ return imp_obj
498
+
499
+
500
+ def _update_existing_implementation_objective(
501
+ ex_obj: ImplementationObjective,
502
+ cis_record: dict,
503
+ responsibility: str,
504
+ cloud_responsibility: str,
505
+ customer_responsibility: str,
506
+ ) -> None:
507
+ """
508
+ Update an existing implementation objective.
509
+
510
+ :param ImplementationObjective ex_obj: Existing implementation objective
511
+ :param dict cis_record: CIS record data
512
+ :param str responsibility: Responsibility value
513
+ :param str cloud_responsibility: Cloud responsibility value
514
+ :param str customer_responsibility: Customer responsibility value
515
+ :rtype: None
516
+ """
517
+ ex_obj.status = get_multi_status(cis_record)
518
+ ex_obj.responsibility = responsibility
519
+ if cloud_responsibility.strip():
520
+ logger.debug(f"Updating Implementation Objective #{ex_obj.id} with responsibility: {responsibility}")
521
+ ex_obj.cloudResponsibility = cloud_responsibility
522
+ if customer_responsibility.strip():
523
+ logger.debug(
524
+ f"Updating Implementation Objective #{ex_obj.id} with cloud responsibility: {cloud_responsibility}"
525
+ )
526
+ ex_obj.customerResponsibility = customer_responsibility
527
+
528
+
472
529
  def update_imp_objective(
473
530
  leverage_auth_id: int,
474
531
  existing_imp_obj: List[ImplementationObjective],
@@ -487,80 +544,49 @@ def update_imp_objective(
487
544
  :rtype: None
488
545
  :return: None
489
546
  """
490
-
491
547
  cis_record = record.get("cis", {})
492
548
  crm_record = record.get("crm", {})
493
- # There could be multiples, take the first one as regscale will not allow multiples at the objective level.
494
- control_originations = cis_record.get("control_origination", "").split(",")
495
- for ix, control_origination in enumerate(control_originations):
496
- control_originations[ix] = control_origination.strip()
497
549
 
498
- try:
499
- if RegscaleVersion.meets_minimum_version("6.20.17.0"):
500
- responsibility = ",".join(control_originations)
501
- else:
502
- responsibility = next(origin for origin in control_originations)
550
+ # Parse and clean control originations
551
+ control_originations = [orig.strip() for orig in cis_record.get("control_origination", "").split(",")]
503
552
 
504
- except StopIteration:
505
- if imp.responsibility:
506
- responsibility = imp.responsibility.split(",")[0] # only one responsiblity allowed here.
507
- else:
508
- responsibility = SERVICE_PROVIDER_CORPORATE
553
+ # Calculate responsibility
554
+ responsibility = _calculate_responsibility(control_originations, imp)
509
555
 
556
+ # Parse responsibility fields
510
557
  customer_responsibility = clean_customer_responsibility(
511
558
  crm_record.get("specific_inheritance_and_customer_agency_csp_responsibilities")
512
559
  )
513
- existing_pairs = {(obj.objectiveId, obj.implementationId) for obj in existing_imp_obj}
514
- logger.debug(f"CRM Record: {crm_record}")
515
560
  can_be_inherited_from_csp: str = crm_record.get("can_be_inherited_from_csp") or ""
516
561
  cloud_responsibility = customer_responsibility if can_be_inherited_from_csp.lower() == "yes" else ""
517
562
  customer_responsibility = customer_responsibility if can_be_inherited_from_csp.lower() != "yes" else ""
563
+
564
+ existing_pairs = {(obj.objectiveId, obj.implementationId) for obj in existing_imp_obj}
565
+ logger.debug(f"CRM Record: {crm_record}")
566
+
518
567
  for objective in objectives:
568
+ if objective.securityControlId != imp.controlID:
569
+ continue
570
+
519
571
  current_pair = (objective.id, imp.id)
520
572
  if current_pair not in existing_pairs:
521
- if objective.securityControlId != imp.controlID:
522
- # This is a bad match, do not save.
523
- continue
524
-
525
- imp_obj = ImplementationObjective(
526
- id=0,
527
- uuid="",
528
- inherited=can_be_inherited_from_csp in ["Yes", "Partial"],
529
- implementationId=imp.id,
530
- status=get_multi_status(cis_record),
531
- objectiveId=objective.id,
532
- notes=objective.name,
533
- securityControlId=objective.securityControlId,
534
- securityPlanId=REGSCALE_SSP_ID,
535
- responsibility=responsibility,
536
- cloudResponsibility=cloud_responsibility,
537
- customerResponsibility=customer_responsibility,
538
- authorizationId=leverage_auth_id,
539
- parentObjectiveId=objective.parentObjectiveId,
540
- )
541
- logger.debug(
542
- "Creating new Implementation Objective for Control %s with status: %s responsibility: %s",
543
- imp_obj.securityControlId,
544
- imp_obj.status,
545
- imp_obj.responsibility,
573
+ imp_obj = _create_new_implementation_objective(
574
+ leverage_auth_id,
575
+ imp,
576
+ objective,
577
+ cis_record,
578
+ responsibility,
579
+ cloud_responsibility,
580
+ customer_responsibility,
581
+ can_be_inherited_from_csp,
546
582
  )
547
583
  UPDATED_IMPLEMENTATION_OBJECTIVES.add(imp_obj)
548
584
  else:
549
585
  ex_obj = next((obj for obj in existing_imp_obj if obj.objectiveId == objective.id), None)
550
586
  if ex_obj:
551
- ex_obj.status = get_multi_status(cis_record)
552
- ex_obj.responsibility = responsibility
553
- if cloud_responsibility.strip():
554
- logger.debug(
555
- f"Updating Implementation Objective #{ex_obj.id} with responsibility: {responsibility}"
556
- )
557
- ex_obj.cloudResponsibility = cloud_responsibility
558
- if customer_responsibility.strip():
559
- logger.debug(
560
- f"Updating Implementation Objective #{ex_obj.id} with cloud responsibility: {cloud_responsibility}"
561
- )
562
- ex_obj.customerResponsibility = customer_responsibility
563
-
587
+ _update_existing_implementation_objective(
588
+ ex_obj, cis_record, responsibility, cloud_responsibility, customer_responsibility
589
+ )
564
590
  UPDATED_IMPLEMENTATION_OBJECTIVES.add(ex_obj)
565
591
 
566
592
 
@@ -689,8 +715,6 @@ def get_all_imps(api: Api, cis_data: dict, version: Literal["rev4", "rev5"]) ->
689
715
  :return: None
690
716
  :rtype: None
691
717
  """
692
- from requests import RequestException
693
-
694
718
  # Check if the response is successful
695
719
  if EXISTING_IMPLEMENTATIONS:
696
720
  # Get Control Implementations For SSP
@@ -765,6 +789,9 @@ def update_all_objectives(
765
789
  """
766
790
 
767
791
  all_control_objectives = get_all_control_objectives(imps=EXISTING_IMPLEMENTATIONS.values())
792
+ # Create ControlMatcher instance for consistent control ID parsing
793
+ control_matcher = ControlMatcher()
794
+
768
795
  error_set = set()
769
796
  process_task = progress.add_task(
770
797
  "[cyan]Processing control objectives...", total=len(EXISTING_IMPLEMENTATIONS.values())
@@ -777,7 +804,13 @@ def update_all_objectives(
777
804
  # Submit all tasks
778
805
  future_to_control = {
779
806
  executor.submit(
780
- process_implementation, leveraged_auth_id, imp, combined_data, version, all_control_objectives
807
+ process_implementation,
808
+ leveraged_auth_id,
809
+ imp,
810
+ combined_data,
811
+ version,
812
+ all_control_objectives,
813
+ control_matcher,
781
814
  ): imp
782
815
  for imp in EXISTING_IMPLEMENTATIONS.values()
783
816
  }
@@ -833,6 +866,7 @@ def process_implementation(
833
866
  sheet_data: dict,
834
867
  version: Literal["rev4", "rev5"],
835
868
  all_objectives: List[ControlObjective],
869
+ control_matcher: ControlMatcher,
836
870
  ) -> Tuple[List[str], List[ImplementationObjective]]:
837
871
  """
838
872
  Processes a single implementation and its associated records.
@@ -842,6 +876,7 @@ def process_implementation(
842
876
  :param dict sheet_data: The CIS/CRM data to process
843
877
  :param Literal["rev4", "rev5"] version: The version of the workbook
844
878
  :param List[ControlObjective] all_objectives: all the control objectives
879
+ :param ControlMatcher control_matcher: ControlMatcher instance for control ID parsing
845
880
  :rtype Tuple[List[str], List[ImplementationObjective]]
846
881
  :returns A list of updated implementation objectives
847
882
  """
@@ -849,7 +884,7 @@ def process_implementation(
849
884
  errors = []
850
885
  processed_objectives = []
851
886
 
852
- existing_objectives, filtered_records = gen_filtered_records(implementation, sheet_data, version)
887
+ existing_objectives, filtered_records = gen_filtered_records(implementation, sheet_data, control_matcher)
853
888
  result = None
854
889
  for record in filtered_records:
855
890
  res = process_single_record(
@@ -859,6 +894,7 @@ def process_implementation(
859
894
  control_objectives=all_objectives,
860
895
  existing_objectives=existing_objectives,
861
896
  version=version,
897
+ control_matcher=control_matcher,
862
898
  )
863
899
  if isinstance(res, tuple):
864
900
  method_errors, result = res
@@ -870,32 +906,35 @@ def process_implementation(
870
906
 
871
907
 
872
908
  def gen_filtered_records(
873
- implementation: ControlImplementation, sheet_data: dict, version: Literal["rev4", "rev5"]
909
+ implementation: ControlImplementation, sheet_data: dict, control_matcher: ControlMatcher
874
910
  ) -> Tuple[List[ImplementationObjective], List[Dict[str, str]]]:
875
911
  """
876
- Generates filtered records for a given implementation.
912
+ Generates filtered records for a given implementation using ControlMatcher.
877
913
 
878
914
  :param ControlImplementation implementation: The control implementation to filter records for
879
915
  :param dict sheet_data: The CIS/CRM data to filter
880
- :param Literal["rev4", "rev5"] version: The version of the workbook
916
+ :param ControlMatcher control_matcher: ControlMatcher instance for control ID matching
881
917
  :returns A tuple of existing objectives, and filtered records
882
918
  :rtype Tuple[List[ImplementationObjective], List[Dict[str, str]]]
883
919
  """
884
920
  security_control = SecurityControl.get_object(implementation.controlID)
885
921
  existing_objectives = ImplementationObjective.get_by_control(implementation.id)
886
- if version == "rev5":
887
- filtered_records = filter(
888
- lambda r: extract_control_name(r["cis"]["regscale_control_id"]).lower()
889
- == security_control.controlId.lower(),
890
- sheet_data.values(),
891
- )
892
- else:
893
- # For rev4, convert OSCAL control ID to control label format and match against original control_id
894
- # e.g., "ac-1" -> "ac-01", then match "AC-01 (a)", "AC-01 (b)", etc.
895
- control_label = _convert_oscal_to_rev4_control_label(security_control.controlId)
896
- filtered_records = filter(
897
- lambda r: r["cis"]["regscale_control_id"].lower() == control_label.lower(), sheet_data.values()
898
- )
922
+
923
+ # Get all variations of the control ID using ControlMatcher
924
+ # pylint: disable=protected-access # Using internal method for control ID variation matching
925
+ control_variations = control_matcher._get_control_id_variations(security_control.controlId)
926
+
927
+ # Filter records that match any variation of the control ID
928
+ filtered_records = []
929
+ for record in sheet_data.values():
930
+ record_control_id = record["cis"].get("regscale_control_id", "")
931
+ # Parse the record's control ID
932
+ parsed_record_id = control_matcher.parse_control_id(record_control_id)
933
+ if parsed_record_id:
934
+ # Check if the parsed record control ID matches any variation
935
+ # pylint: disable=protected-access # Using internal method for control ID variation matching
936
+ if control_variations & control_matcher._get_control_id_variations(parsed_record_id):
937
+ filtered_records.append(record)
899
938
 
900
939
  return existing_objectives, filtered_records
901
940
 
@@ -919,61 +958,28 @@ def process_single_record(**kwargs) -> Tuple[List[str], Optional[ImplementationO
919
958
  :rtype Tuple[List[str], Optional[ImplementationObjective]]
920
959
  :returns A list of errors and the Implementation Objective if successful, otherwise None
921
960
  """
922
- # No longer need to load JSON mappings - using smart algorithm only
923
-
924
961
  errors = []
925
- version = kwargs.get("version")
926
962
  leveraged_auth_id: int = kwargs.get("leveraged_auth_id")
927
963
  implementation: ControlImplementation = kwargs.get("implementation")
928
964
  record: dict = kwargs.get("record")
929
965
  control_objectives: List[ControlObjective] = kwargs.get("control_objectives")
930
966
  existing_objectives: List[ImplementationObjective] = kwargs.get("existing_objectives")
931
- mapped_objectives: List[ControlObjective] = []
967
+ control_matcher: ControlMatcher = kwargs.get("control_matcher")
932
968
  result = None
933
969
 
934
970
  # Get the control ID from the CIS/CRM record
935
971
  key = record["cis"]["control_id"]
936
972
 
937
- # Use smart algorithm to find mapping
938
- source, parts, status = smart_find_by_source(key, control_objectives)
973
+ # Use ControlMatcher to find matching objectives
974
+ mapped_objectives, status = find_objectives_using_control_matcher(key, control_objectives, control_matcher)
939
975
 
940
- logger.debug(f"Smart mapping result for {key}: {status}")
976
+ logger.debug(f"Control matching result for {key}: {status}")
941
977
 
942
- # Add to errors list if status does not start with "Found"
943
- if not status.startswith("Found"):
978
+ # Add to errors list if no objectives found
979
+ if not mapped_objectives:
944
980
  errors.append(f"{key}: {status}")
945
-
946
- # Process exact match if found
947
- if source:
948
- try:
949
- objective = next(
950
- obj
951
- for obj in control_objectives
952
- if (obj.otherId == source and version in ["rev5", "rev4"]) or (obj.name == source and version == "rev4")
953
- )
954
- mapped_objectives.append(objective)
955
- except StopIteration:
956
- logger.debug(f"Missing Source: {source}")
957
- errors.append(f"Unable to find objective for control {key} ({source})")
958
-
959
- # Process sub-parts if found
960
- if parts:
961
- for part in parts:
962
- try:
963
- if version == "rev5":
964
- mapped_objectives.append(next(obj for obj in control_objectives if obj.otherId == part))
965
- else:
966
- mapped_objectives.append(
967
- next(obj for obj in control_objectives if obj.otherId == part or obj.name == part)
968
- )
969
- except StopIteration:
970
- errors.append(f"Unable to find part {part} for control {key}")
971
-
972
- # Report if no mapping found at all
973
- if not source and not parts:
974
- errors.append(f"Unable to find source and part for control {key}")
975
-
976
- if mapped_objectives:
981
+ else:
982
+ # Update implementation objectives with the matched control objectives
977
983
  update_imp_objective(
978
984
  leverage_auth_id=leveraged_auth_id,
979
985
  existing_imp_obj=existing_objectives,
@@ -1023,46 +1029,53 @@ def parse_crm_worksheet(file_path: click.Path, crm_sheet_name: str, version: Lit
1023
1029
 
1024
1030
  logger.debug(f"Skipping {skip_rows} rows in CRM worksheet")
1025
1031
 
1026
- # only use thse coloumns
1027
- usecols = [
1032
+ # Define required columns
1033
+ required_columns = [
1028
1034
  CONTROL_ID,
1029
1035
  "Can Be Inherited from CSP",
1030
1036
  "Specific Inheritance and Customer Agency/CSP Responsibilities",
1031
1037
  ]
1032
1038
 
1033
1039
  try:
1034
- # Verify that the columns are in the dataframe
1035
- header_row = validator.data.iloc[skip_rows - 1 :].iloc[0]
1040
+ # Get the header row (which is at skip_rows - 1)
1041
+ header_row = validator.data.iloc[skip_rows - 1]
1036
1042
 
1037
- # Check if we have enough columns
1038
- if len(header_row) < len(usecols):
1039
- error_and_exit(
1040
- f"Not enough columns found in CRM worksheet. Expected {len(usecols)} columns but found {len(header_row)}."
1041
- )
1043
+ # Get data rows starting from skip_rows
1044
+ data = validator.data.iloc[skip_rows:].reset_index(drop=True)
1042
1045
 
1043
- # Verify each required column exists in the correct position
1046
+ # Set column names from header row
1047
+ data.columns = header_row
1048
+
1049
+ # Find required columns by name (case-insensitive, handle extra columns in AWS CIS/CRM)
1050
+ available_columns = data.columns.tolist()
1051
+ columns_to_use = []
1044
1052
  missing_columns = []
1045
- for i, expected_col in enumerate(usecols):
1046
- if header_row.iloc[i] != expected_col:
1047
- missing_columns.append(
1048
- f"Expected '{expected_col}' at position {i + 1} but found '{header_row.iloc[i]}'"
1049
- )
1053
+
1054
+ for required_col in required_columns:
1055
+ # Find column that matches (case-insensitive, strip whitespace)
1056
+ matching_col = next(
1057
+ (col for col in available_columns if str(col).strip().lower() == required_col.lower()), None
1058
+ )
1059
+ if matching_col is not None:
1060
+ columns_to_use.append(matching_col)
1061
+ else:
1062
+ missing_columns.append(required_col)
1050
1063
 
1051
1064
  if missing_columns:
1052
- error_msg = "Required columns not found in the CRM worksheet:\n" + "\n".join(missing_columns)
1065
+ error_msg = (
1066
+ f"Required columns not found in the CRM worksheet: {', '.join(missing_columns)}\n"
1067
+ f"Available columns: {', '.join(str(col) for col in available_columns)}"
1068
+ )
1053
1069
  error_and_exit(error_msg)
1054
1070
 
1055
- logger.debug("Verified all required columns exist in CRM worksheet")
1071
+ logger.debug(f"Found all required columns in CRM worksheet: {', '.join(required_columns)}")
1056
1072
 
1057
- # Reindex the dataframe and skip some rows
1058
- data = validator.data.iloc[skip_rows:]
1073
+ # Keep only the required columns
1074
+ data = data[columns_to_use]
1059
1075
 
1060
- # Keep only the first three columns
1061
- data = data.iloc[:, :3]
1062
-
1063
- # Rename the columns to match usecols
1064
- data.columns = usecols
1065
- logger.debug(f"Kept only required columns: {', '.join(usecols)}")
1076
+ # Rename the columns to standardize names
1077
+ data.columns = required_columns
1078
+ logger.debug(f"Using columns: {', '.join(required_columns)}")
1066
1079
 
1067
1080
  except KeyError as e:
1068
1081
  error_and_exit(f"KeyError: {e} - One or more columns specified in usecols are not found in the dataframe.")
@@ -1098,19 +1111,66 @@ def parse_crm_worksheet(file_path: click.Path, crm_sheet_name: str, version: Lit
1098
1111
  return formatted_crm
1099
1112
 
1100
1113
 
1101
- def parse_cis_worksheet(file_path: click.Path, cis_sheet_name: str) -> dict:
1114
+ def _get_expected_cis_columns() -> List[str]:
1102
1115
  """
1103
- Function to parse and format the CIS worksheet content
1116
+ Get the expected column names for CIS worksheet in order.
1104
1117
 
1105
- :param click.Path file_path: The file path to the FedRAMP CIS CRM workbook
1106
- :param str cis_sheet_name: The name of the CIS sheet to parse
1107
- :return: Formatted CIS content
1108
- :rtype: dict
1118
+ :return: List of expected column names
1119
+ :rtype: List[str]
1109
1120
  """
1110
- pd = get_pandas()
1111
- logger.info("Parsing CIS worksheet...")
1112
- skip_rows = 2
1121
+ return [
1122
+ CONTROL_ID,
1123
+ "Implemented",
1124
+ ControlImplementationStatus.PartiallyImplemented,
1125
+ "Planned",
1126
+ ALT_IMPLEMENTATION,
1127
+ ControlImplementationStatus.NA,
1128
+ SERVICE_PROVIDER_CORPORATE,
1129
+ SERVICE_PROVIDER_SYSTEM_SPECIFIC,
1130
+ SERVICE_PROVIDER_HYBRID,
1131
+ CONFIGURED_BY_CUSTOMER,
1132
+ PROVIDED_BY_CUSTOMER,
1133
+ SHARED,
1134
+ INHERITED,
1135
+ ]
1136
+
1137
+
1138
+ def _normalize_cis_columns(cis_df, expected_columns: List[str]):
1139
+ """
1140
+ Normalize CIS dataframe columns by matching expected columns and handling missing ones.
1141
+
1142
+ :param cis_df: The CIS dataframe
1143
+ :param List[str] expected_columns: List of expected column names
1144
+ :return: Normalized dataframe with standardized column names
1145
+ """
1146
+ available_columns = cis_df.columns.tolist()
1147
+ columns_to_keep = []
1148
+
1149
+ for expected_col in expected_columns:
1150
+ matching_col = next(
1151
+ (col for col in available_columns if str(col).strip().lower() == expected_col.lower()), None
1152
+ )
1153
+ if matching_col is not None:
1154
+ columns_to_keep.append(matching_col)
1155
+ else:
1156
+ logger.warning(f"Expected column '{expected_col}' not found in CIS worksheet. Using empty values.")
1157
+ cis_df[expected_col] = ""
1158
+ columns_to_keep.append(expected_col)
1159
+
1160
+ cis_df = cis_df[columns_to_keep]
1161
+ cis_df.columns = expected_columns
1162
+ return cis_df.fillna("")
1113
1163
 
1164
+
1165
+ def _load_and_prepare_cis_dataframe(file_path: click.Path, cis_sheet_name: str, skip_rows: int):
1166
+ """
1167
+ Load and prepare the CIS dataframe from the workbook.
1168
+
1169
+ :param click.Path file_path: The file path to the workbook
1170
+ :param str cis_sheet_name: The sheet name to parse
1171
+ :param int skip_rows: Number of rows to skip
1172
+ :return: Tuple of (prepared dataframe, updated skip_rows) or (None, skip_rows) if empty
1173
+ """
1114
1174
  validator = ImportValidater(
1115
1175
  file_path=file_path,
1116
1176
  disable_mapping=True,
@@ -1122,35 +1182,49 @@ def parse_cis_worksheet(file_path: click.Path, cis_sheet_name: str) -> dict:
1122
1182
  warn_extra_headers=False,
1123
1183
  )
1124
1184
  if validator.data.empty:
1125
- return {}
1185
+ return None, skip_rows
1126
1186
 
1127
1187
  skip_rows = determine_skip_row(original_df=validator.data, text_to_find=CONTROL_ID, original_skip=skip_rows)
1128
1188
 
1129
- # Parse the worksheet named 'CIS GovCloud U.S.+DoD (H)', skipping the initial rows
1130
- original_cis = validator.data
1131
-
1132
- cis_df = original_cis.iloc[skip_rows:].reset_index(drop=True)
1133
-
1134
- # Set the appropriate headers
1189
+ cis_df = validator.data.iloc[skip_rows:].reset_index(drop=True)
1135
1190
  cis_df.columns = cis_df.iloc[0]
1136
-
1137
- # Drop any fully empty rows
1138
1191
  cis_df.dropna(how="all", inplace=True)
1139
-
1140
- # Reset the index
1141
1192
  cis_df.reset_index(drop=True, inplace=True)
1142
1193
 
1143
- # Only keep the first 13 columns
1144
- cis_df = cis_df.iloc[:, :13]
1194
+ return cis_df, skip_rows
1145
1195
 
1146
- # Rename columns to standardize names
1147
- cis_df.columns = [
1148
- CONTROL_ID,
1196
+
1197
+ def _extract_status(data_row) -> str:
1198
+ """
1199
+ Extract the first non-empty implementation status from the CIS worksheet.
1200
+
1201
+ :param data_row: The data row to extract the status from
1202
+ :return: The implementation status
1203
+ :rtype: str
1204
+ """
1205
+ selected_status = []
1206
+ for col in [
1149
1207
  "Implemented",
1150
1208
  ControlImplementationStatus.PartiallyImplemented,
1151
1209
  "Planned",
1152
1210
  ALT_IMPLEMENTATION,
1153
1211
  ControlImplementationStatus.NA,
1212
+ ]:
1213
+ if data_row[col]:
1214
+ selected_status.append(col)
1215
+ return ", ".join(selected_status) if selected_status else ""
1216
+
1217
+
1218
+ def _extract_origination(data_row) -> str:
1219
+ """
1220
+ Extract the first non-empty control origination from the CIS worksheet.
1221
+
1222
+ :param data_row: The data row to extract the origination from
1223
+ :return: The control origination
1224
+ :rtype: str
1225
+ """
1226
+ selected_origination = []
1227
+ for col in [
1154
1228
  SERVICE_PROVIDER_CORPORATE,
1155
1229
  SERVICE_PROVIDER_SYSTEM_SPECIFIC,
1156
1230
  SERVICE_PROVIDER_HYBRID,
@@ -1158,75 +1232,53 @@ def parse_cis_worksheet(file_path: click.Path, cis_sheet_name: str) -> dict:
1158
1232
  PROVIDED_BY_CUSTOMER,
1159
1233
  SHARED,
1160
1234
  INHERITED,
1161
- ]
1235
+ ]:
1236
+ if data_row[col]:
1237
+ selected_origination.append(col)
1238
+ return ", ".join(selected_origination) if selected_origination else ""
1162
1239
 
1163
- # Fill NaN values with an empty string for processing
1164
- cis_df = cis_df.fillna("")
1165
-
1166
- # Function to extract the first non-empty implementation status
1167
- def _extract_status(data_row: pd.Series) -> str:
1168
- """
1169
- Function to extract the first non-empty implementation status from the CIS worksheet
1170
-
1171
- :param pd.Series data_row: The data row to extract the status from
1172
- :return: The implementation status
1173
- :rtype: str
1174
- """
1175
- selected_status = []
1176
- for col in [
1177
- "Implemented",
1178
- ControlImplementationStatus.PartiallyImplemented,
1179
- "Planned",
1180
- ALT_IMPLEMENTATION,
1181
- ControlImplementationStatus.NA,
1182
- ]:
1183
- if data_row[col]:
1184
- selected_status.append(col)
1185
- return ", ".join(selected_status) if selected_status else ""
1186
-
1187
- # Function to extract the first non-empty control origination
1188
- def _extract_origination(data_row: pd.Series) -> str:
1189
- """
1190
- Function to extract the first non-empty control origination from the CIS worksheet
1191
-
1192
- :param pd.Series data_row: The data row to extract the origination from
1193
- :return: The control origination
1194
- :rtype: str
1195
- """
1196
- selected_origination = []
1197
- for col in [
1198
- SERVICE_PROVIDER_CORPORATE,
1199
- SERVICE_PROVIDER_SYSTEM_SPECIFIC,
1200
- SERVICE_PROVIDER_HYBRID,
1201
- CONFIGURED_BY_CUSTOMER,
1202
- PROVIDED_BY_CUSTOMER,
1203
- SHARED,
1204
- INHERITED,
1205
- ]:
1206
- if data_row[col]:
1207
- selected_origination.append(col)
1208
- return ", ".join(selected_origination) if selected_origination else ""
1209
-
1210
- def _process_row(row: pd.Series) -> dict:
1211
- """
1212
- Function to process a row from the CIS worksheet
1213
-
1214
- :param pd.Series row: The row to process
1215
- :return: The processed row
1216
- :rtype: dict
1217
- """
1218
- return {
1219
- "control_id": row[CONTROL_ID],
1220
- "regscale_control_id": transform_control(row[CONTROL_ID]),
1221
- "implementation_status": _extract_status(row),
1222
- "control_origination": _extract_origination(row),
1223
- }
1224
1240
 
1225
- # use a threadexecutor to process the rows in parallel
1241
+ def _process_cis_row(row) -> dict:
1242
+ """
1243
+ Process a row from the CIS worksheet.
1244
+
1245
+ :param row: The row to process
1246
+ :return: The processed row
1247
+ :rtype: dict
1248
+ """
1249
+ return {
1250
+ "control_id": row[CONTROL_ID],
1251
+ "regscale_control_id": transform_control(row[CONTROL_ID]),
1252
+ "implementation_status": _extract_status(row),
1253
+ "control_origination": _extract_origination(row),
1254
+ }
1255
+
1256
+
1257
+ def parse_cis_worksheet(file_path: click.Path, cis_sheet_name: str) -> dict:
1258
+ """
1259
+ Function to parse and format the CIS worksheet content
1260
+
1261
+ :param click.Path file_path: The file path to the FedRAMP CIS CRM workbook
1262
+ :param str cis_sheet_name: The name of the CIS sheet to parse
1263
+ :return: Formatted CIS content
1264
+ :rtype: dict
1265
+ """
1266
+ logger.info("Parsing CIS worksheet...")
1267
+
1268
+ # Load and prepare the dataframe
1269
+ cis_df, _ = _load_and_prepare_cis_dataframe(file_path, cis_sheet_name, skip_rows=2)
1270
+ if cis_df is None:
1271
+ return {}
1272
+
1273
+ # Get expected columns and normalize the dataframe
1274
+ expected_columns = _get_expected_cis_columns()
1275
+ cis_df = _normalize_cis_columns(cis_df, expected_columns)
1276
+
1277
+ # Process rows in parallel
1226
1278
  with ThreadPoolExecutor() as executor:
1227
- results = list(executor.map(_process_row, [row for _, row in cis_df.iterrows()]))
1279
+ results = list(executor.map(_process_cis_row, [row for _, row in cis_df.iterrows()]))
1228
1280
 
1229
- # iterate the results and index by control_id
1281
+ # Index by control_id
1230
1282
  return {clean_key(result["control_id"]): result for result in results}
1231
1283
 
1232
1284
 
@@ -1589,7 +1641,7 @@ def create_new_security_plan(profile_id: int, system_name: str):
1589
1641
 
1590
1642
  else:
1591
1643
  INITIAL_IMPORT = False
1592
- ret = next((plan for plan in existing_plan), None)
1644
+ ret = next(iter(existing_plan), None)
1593
1645
  logger.info(f"Found existing SSP# {ret.id}")
1594
1646
  create_backup_file(ret.id)
1595
1647
  existing_imps = ControlImplementation.get_list_by_plan(ret.id)
@@ -1778,7 +1830,8 @@ def parse_and_import_ciscrm(
1778
1830
  cis_data = parse_cis_worksheet(file_path=file_path, cis_sheet_name=cis_sheet_name)
1779
1831
  crm_data = {}
1780
1832
  if crm_sheet_name:
1781
- crm_data = parse_crm_worksheet(file_path=file_path, crm_sheet_name=crm_sheet_name, version=version) # type: ignore
1833
+ # type: ignore
1834
+ crm_data = parse_crm_worksheet(file_path=file_path, crm_sheet_name=crm_sheet_name, version=version)
1782
1835
  if leveraged_auth_id == 0:
1783
1836
  auths = LeveragedAuthorization.get_all_by_parent(ssp.id)
1784
1837
  if auths: